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:
@@ -125,3 +125,112 @@ processing_activities:
|
||||
- tls_1_3_to_skatteverket
|
||||
- bankid_signing_required_for_filing
|
||||
- immutable_audit_log
|
||||
|
||||
- id: psd2.cash_account_mirror
|
||||
name: PSD2-konton speglas till cash_accounts
|
||||
purpose: >-
|
||||
När en bank-anslutning via Enable Banking returnerar kontolista efter
|
||||
lyckad PSD2-consent kopieras kontometadata (IBAN, valuta, kontonamn,
|
||||
external_uid) till cash_accounts så att avstämning, motkontoresolver
|
||||
och __PRIMARY_SEK__-sentinel kan rutta verifikat utan att läsa JSONB
|
||||
från bank_connections.accounts_data vid varje fråga.
|
||||
lawful_basis: art_6_1_b # contract (PSD2 consent + bookkeeping service)
|
||||
special_category_basis: null
|
||||
controller: gnubok-tenant
|
||||
processor: anthropic-na
|
||||
data_subjects:
|
||||
- business_owner
|
||||
data_categories:
|
||||
- user.financial.bank_account # IBAN
|
||||
- user.contact # account name (kontotitel)
|
||||
recipients:
|
||||
- name: Supabase
|
||||
country: EU
|
||||
role: processor
|
||||
international_transfers:
|
||||
applicable: false
|
||||
mechanism: null
|
||||
note: EU-only processor; no third-country transfer.
|
||||
retention:
|
||||
duration: consent_lifetime
|
||||
basis: psd2_consent
|
||||
stored_in:
|
||||
- cash_accounts
|
||||
- bank_connections.accounts_data
|
||||
security_measures:
|
||||
- rls_company_scoped
|
||||
- data_minimization_no_balance_on_callback
|
||||
- failure_emitted_to_event_log
|
||||
|
||||
- id: transactions.counterparty_iban
|
||||
name: Motpartens IBAN på transaktioner
|
||||
purpose: >-
|
||||
Spara motpartens IBAN på transaktionsraden så att own-account-detector
|
||||
kan identifiera överföringar mellan företagets egna konton (1930 → 1932
|
||||
etc.) och bokföra båda benen automatiskt istället för att felbokföra
|
||||
utflödet som extern kostnad. Krävs även för payment-matchning mot
|
||||
leverantörsfakturor.
|
||||
lawful_basis: art_6_1_b # contract (bookkeeping service)
|
||||
special_category_basis: null
|
||||
controller: gnubok-tenant
|
||||
processor: anthropic-na
|
||||
data_subjects:
|
||||
- business_owner
|
||||
- counterparty
|
||||
data_categories:
|
||||
- user.financial.bank_account # counterparty IBAN
|
||||
recipients:
|
||||
- name: Supabase
|
||||
country: EU
|
||||
role: processor
|
||||
international_transfers:
|
||||
applicable: false
|
||||
mechanism: null
|
||||
note: EU-only processor.
|
||||
retention:
|
||||
duration: 7y
|
||||
basis: bfl_7_kap
|
||||
stored_in:
|
||||
- transactions.counterparty_iban
|
||||
security_measures:
|
||||
- rls_company_scoped
|
||||
- immutable_after_post
|
||||
|
||||
- id: skattekonto.drift_alert
|
||||
name: Skattekonto-drift via e-post
|
||||
purpose: >-
|
||||
Underrätta företagets kontaktadress när det cachade Skatteverket-saldot
|
||||
avviker från GL 1630 utöver konfigurerad tolerans (> 1 SEK), så
|
||||
bokföraren kan granska skattekonto-raderna. E-postmeddelandet
|
||||
innehåller ingen finansiell siffra utan en länk till autentiserad
|
||||
dashboard; mottagaren valideras mot company_members innan utskick.
|
||||
lawful_basis: art_6_1_f # legitimate interest (bookkeeping accuracy)
|
||||
special_category_basis: null
|
||||
controller: gnubok-tenant
|
||||
processor: resend
|
||||
data_subjects:
|
||||
- business_owner
|
||||
- company_member
|
||||
data_categories:
|
||||
- user.contact.email
|
||||
recipients:
|
||||
- name: Resend
|
||||
country: US
|
||||
role: processor
|
||||
international_transfers:
|
||||
applicable: true
|
||||
mechanism: scc_2021_c2p
|
||||
note: >-
|
||||
Resend (US) — SCC Module 2 (controller-to-processor). Outbound
|
||||
payload limited to ett notifieringsmail utan finansiella belopp;
|
||||
TIA dokumenterad i .compliance/tia/resend.md.
|
||||
retention:
|
||||
duration: 30d
|
||||
basis: event_log_ttl
|
||||
stored_in:
|
||||
- event_log
|
||||
security_measures:
|
||||
- recipient_membership_check_before_send
|
||||
- no_financial_figures_in_body
|
||||
- tls_to_resend
|
||||
- rls_company_scoped
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
import { createClient } from '@/lib/supabase/server'
|
||||
import { NextResponse } from 'next/server'
|
||||
import { getActiveCompanyId } from '@/lib/company/context'
|
||||
import { listForCompany } from '@/lib/cash-accounts/service'
|
||||
|
||||
/**
|
||||
* GET /api/cash-accounts
|
||||
*
|
||||
* Returns the active company's cash accounts (cash_accounts table). Used by the
|
||||
* reconciliation CashAccountSelector (Item 5) and any other surface that needs
|
||||
* the canonical list of routable cash accounts. UI panels that just display PSD2
|
||||
* connection state may still read bank_connections.accounts_data until that
|
||||
* column is dropped in a follow-up migration.
|
||||
*
|
||||
* Query params:
|
||||
* - enabled_only=true → only accounts with enabled=true (default returns all)
|
||||
*/
|
||||
export async function GET(request: Request) {
|
||||
const supabase = await createClient()
|
||||
const { data: { user } } = await supabase.auth.getUser()
|
||||
if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
|
||||
const companyId = await getActiveCompanyId(supabase, user.id)
|
||||
if (!companyId) {
|
||||
return NextResponse.json({ error: 'No company context' }, { status: 400 })
|
||||
}
|
||||
|
||||
const url = new URL(request.url)
|
||||
const enabledOnly = url.searchParams.get('enabled_only') === 'true'
|
||||
|
||||
const accounts = await listForCompany(supabase, companyId, { enabledOnly })
|
||||
return NextResponse.json({ data: accounts })
|
||||
}
|
||||
@@ -3,6 +3,21 @@ import { NextResponse } from 'next/server'
|
||||
import { createSession, type AccountInfo } from '@/extensions/general/enable-banking/lib/api-client'
|
||||
import type { StoredAccount } from '@/extensions/general/enable-banking/types'
|
||||
import { eventBus } from '@/lib/events/bus'
|
||||
import { upsertFromPsd2 } from '@/lib/cash-accounts/service'
|
||||
|
||||
// Suggested BAS account per currency. Mirrors the AccountPickerDialog defaults
|
||||
// (SEK→1930, EUR→1932, USD→1933, GBP→1934). The user can re-map in the picker
|
||||
// after this callback redirects them.
|
||||
const CURRENCY_DEFAULTS: Record<string, string> = {
|
||||
SEK: '1930',
|
||||
EUR: '1932',
|
||||
USD: '1933',
|
||||
GBP: '1934',
|
||||
}
|
||||
|
||||
function defaultLedgerForCurrency(currency: string): string {
|
||||
return CURRENCY_DEFAULTS[currency.toUpperCase()] ?? '1930'
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/extensions/enable-banking/callback
|
||||
@@ -170,6 +185,56 @@ export async function GET(request: Request) {
|
||||
throw new Error(`Failed to update connection: ${updateError.message}`)
|
||||
}
|
||||
|
||||
// Mirror each PSD2 account into cash_accounts so routing decisions read from
|
||||
// the canonical entity table. The user picks a ledger_account in the
|
||||
// AccountPickerDialog after this redirect; until then we route SEK→1930,
|
||||
// EUR→1932, USD→1933, GBP→1934 by convention.
|
||||
for (const account of accountsMetadata) {
|
||||
const targetLedger = defaultLedgerForCurrency(account.currency)
|
||||
try {
|
||||
await upsertFromPsd2(supabase, updatedConnection.company_id, {
|
||||
bank_connection_id: updatedConnection.id,
|
||||
external_uid: account.uid,
|
||||
currency: account.currency,
|
||||
ledger_account: targetLedger,
|
||||
iban: account.iban ?? null,
|
||||
name: account.name ?? null,
|
||||
enabled: account.enabled ?? true,
|
||||
})
|
||||
} catch (cashErr) {
|
||||
const reason = cashErr instanceof Error ? cashErr.message : String(cashErr)
|
||||
console.error('[enable-banking] Failed to mirror cash_account on callback', {
|
||||
connectionId: updatedConnection.id,
|
||||
uid: account.uid,
|
||||
error: reason,
|
||||
})
|
||||
// Persist the failure to event_log so a security review can see that
|
||||
// a PSD2 account returned by the bank was not mirrored into our
|
||||
// routing table — otherwise this is only visible in console output
|
||||
// (ASVS V16 / ISO 27001 A.8.15 / SOC 2 CC7.2).
|
||||
try {
|
||||
await eventBus.emit({
|
||||
type: 'bank_connection.cash_account_mirror_failed',
|
||||
payload: {
|
||||
connectionId: updatedConnection.id,
|
||||
bankName: updatedConnection.bank_name ?? null,
|
||||
accountUid: account.uid,
|
||||
ledgerAccount: targetLedger,
|
||||
currency: account.currency,
|
||||
reason,
|
||||
userId: updatedConnection.user_id,
|
||||
companyId: updatedConnection.company_id,
|
||||
},
|
||||
})
|
||||
} catch (emitError) {
|
||||
console.error('[enable-banking] Failed to emit cash_account_mirror_failed event', {
|
||||
connectionId: updatedConnection.id,
|
||||
error: emitError instanceof Error ? emitError.message : String(emitError),
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Audit trail: PSD2 consent has been exchanged and account metadata stored.
|
||||
// ASVS V16 requires this transition to be logged as a security event; emit
|
||||
// here so the event_log handler persists it (30-day TTL).
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
import { createClient } from '@/lib/supabase/server'
|
||||
import { NextResponse } from 'next/server'
|
||||
import { ensureInitialized } from '@/lib/init'
|
||||
import { requireCompanyId } from '@/lib/company/context'
|
||||
import { createExtensionContext } from '@/lib/extensions/context-factory'
|
||||
import { computeSkattekontoDrift } from '@/extensions/general/skatteverket/lib/skattekonto-drift'
|
||||
import { createLogger } from '@/lib/logger'
|
||||
|
||||
ensureInitialized()
|
||||
|
||||
const log = createLogger('skattekonto-drift-route')
|
||||
|
||||
/**
|
||||
* GET /api/extensions/skatteverket/skattekonto/drift
|
||||
*
|
||||
* Returns the current SKV saldo vs GL 1630 drift snapshot for the active
|
||||
* company. Backs the dashboard SkattekontoDriftTile. Returns null when no
|
||||
* snapshot exists yet (fresh company, never synced).
|
||||
*
|
||||
* Access is recorded through the structured logger (Sentry / Vercel logs)
|
||||
* because the response carries sensitive GL drift figures. Persisting every
|
||||
* dashboard tile poll into event_log would be too noisy — the structured
|
||||
* log line gives an auditable record without overrunning the 30-day event
|
||||
* log retention (SOC 2 CC8.1, ISO 27001 A.8.15).
|
||||
*/
|
||||
export async function GET(_request: Request) {
|
||||
const supabase = await createClient()
|
||||
const { data: { user } } = await supabase.auth.getUser()
|
||||
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const companyId = await requireCompanyId(supabase, user.id)
|
||||
const ctx = createExtensionContext(supabase, user.id, companyId, 'skatteverket')
|
||||
|
||||
const drift = await computeSkattekontoDrift(ctx)
|
||||
log.info('skattekonto drift snapshot accessed', {
|
||||
userId: user.id,
|
||||
companyId,
|
||||
hasDrift: drift !== null,
|
||||
})
|
||||
return NextResponse.json({ data: drift })
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import { ensureInitialized } from '@/lib/init'
|
||||
import { verifyCronSecret } from '@/lib/auth/cron'
|
||||
import { createExtensionContext } from '@/lib/extensions/context-factory'
|
||||
import { syncSkattekonto, SKATTEKONTO_LAST_SYNCED_AT_KEY } from '@/extensions/general/skatteverket/lib/skattekonto-sync'
|
||||
import { computeSkattekontoDrift, maybeAlertDrift } from '@/extensions/general/skatteverket/lib/skattekonto-drift'
|
||||
import { SkatteverketAuthError } from '@/extensions/general/skatteverket/lib/api-client'
|
||||
import { SkatteverketSkattekontoError } from '@/extensions/general/skatteverket/lib/skattekonto-client'
|
||||
|
||||
@@ -115,6 +116,19 @@ export async function GET(request: Request) {
|
||||
const ctx = createExtensionContext(supabase, userId, companyId, 'skatteverket')
|
||||
const syncResult = await syncSkattekonto(ctx)
|
||||
|
||||
// Drift check: compare the fresh SKV saldo against GL 1630 sum. Emits
|
||||
// `skattekonto.drift_detected` when |drift| > tolerance and not throttled.
|
||||
try {
|
||||
const drift = await computeSkattekontoDrift(ctx)
|
||||
if (drift) await maybeAlertDrift(ctx, drift)
|
||||
} catch (driftErr) {
|
||||
console.error('[skattekonto-sync-cron] Drift check failed', {
|
||||
userId,
|
||||
companyId,
|
||||
message: driftErr instanceof Error ? driftErr.message : String(driftErr),
|
||||
})
|
||||
}
|
||||
|
||||
results.push({
|
||||
userId,
|
||||
companyId,
|
||||
|
||||
@@ -5,7 +5,14 @@ import { createAuthCode } from '@/lib/auth/oauth-codes'
|
||||
import { requireCompanyId } from '@/lib/company/context'
|
||||
import { getBranding } from '@/lib/branding/service'
|
||||
import { isAllowedRedirectUri } from '@/lib/auth/oauth-allowlist'
|
||||
import { API_KEY_SCOPES, type ApiKeyScope } from '@/lib/auth/api-keys'
|
||||
import {
|
||||
ALL_SCOPES,
|
||||
API_KEY_SCOPES,
|
||||
DEFAULT_OAUTH_SCOPES,
|
||||
SCOPE_GROUPS,
|
||||
validateScopes,
|
||||
type ApiKeyScope,
|
||||
} from '@/lib/auth/api-keys'
|
||||
|
||||
/**
|
||||
* OAuth 2.0 Authorization Endpoint.
|
||||
@@ -23,20 +30,21 @@ type ScopeParseResult =
|
||||
|
||||
/**
|
||||
* Parse the OAuth `scope` query param (RFC 6749 §3.3 — space-delimited list)
|
||||
* into the subset of API_KEY_SCOPES that the user actually granted.
|
||||
* into the subset of API_KEY_SCOPES the client is asking for. Used to drive
|
||||
* pre-checked defaults on the consent UI; the user's actual grant comes from
|
||||
* their checkbox selection.
|
||||
*
|
||||
* Returns:
|
||||
* - { ok, scopes: undefined } when no scope param was supplied — the token
|
||||
* endpoint will fall back to DEFAULT_OAUTH_SCOPES (read-only, GDPR
|
||||
* Art.25(2) data-protection-by-default).
|
||||
* - { ok, scopes: undefined } when no scope param was supplied — the consent
|
||||
* UI pre-checks DEFAULT_OAUTH_SCOPES (read-only, GDPR Art. 25(2)).
|
||||
* - { ok, scopes: [...] } when at least one valid scope was requested.
|
||||
* - { invalid_scope } when a scope param was supplied but every value was
|
||||
* unknown — refusing the request is safer than silently widening the
|
||||
* grant to ALL_SCOPES (V10.2.6).
|
||||
* unknown — refusing the request is safer than silently dropping it back
|
||||
* to defaults the caller didn't ask for (V10.2.6).
|
||||
*
|
||||
* The bare `mcp` marker is treated as "no granular scopes" and accepted for
|
||||
* backwards compatibility with Claude's connector — it falls through to
|
||||
* `undefined` so the default-OAuth fallback applies.
|
||||
* `undefined` so the read-only defaults apply.
|
||||
*/
|
||||
function parseRequestedScopes(scopeParam: string | null): ScopeParseResult {
|
||||
if (!scopeParam) return { kind: 'ok', scopes: undefined }
|
||||
@@ -179,12 +187,27 @@ export async function GET(request: Request) {
|
||||
|
||||
const appNameLower = escapeHtml(getBranding().appName.toLowerCase())
|
||||
|
||||
// CSP nonce for the inline consent UI controls. A nonce-bound script-src
|
||||
// makes the inline block executable while keeping the rest of the page
|
||||
// immune to script injection — without this the consent page is
|
||||
// incompatible with a strict CSP and counts as unsafe-inline (ASVS V3.3,
|
||||
// SOC 2 CC6.1). The nonce is regenerated per response.
|
||||
const cspNonce = crypto.randomBytes(16).toString('base64')
|
||||
|
||||
// Bind the requested scope to the consent display. The HMAC signature is
|
||||
// verified on POST so a tampered form submission cannot widen the grant
|
||||
// beyond what the user actually saw (V10.3.1).
|
||||
const scopeBindingValue = scopeParam ?? ''
|
||||
const scopeBindingSignature = signScopeBinding(scopeBindingValue)
|
||||
|
||||
// The grant ceiling is the set of scopes the client requested, or
|
||||
// DEFAULT_OAUTH_SCOPES when the client passed no scope param. Pre-checks
|
||||
// everything in the ceiling. The POST handler enforces the same ceiling
|
||||
// server-side so a tampered form can't widen the grant past what the
|
||||
// client actually asked for (RFC 6749 §3.3, SOC 2 CC6.3).
|
||||
const grantCeiling = new Set<ApiKeyScope>(parsed.scopes ?? DEFAULT_OAUTH_SCOPES)
|
||||
const scopeCheckboxesHtml = renderScopeCheckboxes(grantCeiling, grantCeiling)
|
||||
|
||||
// Render consent page
|
||||
const html = `<!DOCTYPE html>
|
||||
<html lang="sv">
|
||||
@@ -195,15 +218,26 @@ export async function GET(request: Request) {
|
||||
<title>Anslut MCP-klient — ${appNameLower}</title>
|
||||
<style>
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
body { font-family: system-ui, -apple-system, sans-serif; background: #fafafa; color: #111; display: flex; align-items: center; justify-content: center; min-height: 100vh; padding: 1rem; }
|
||||
.card { background: white; border-radius: 12px; border: 1px solid #e5e5e5; padding: 2rem; max-width: 400px; width: 100%; }
|
||||
body { font-family: system-ui, -apple-system, sans-serif; background: #fafafa; color: #111; display: flex; align-items: flex-start; justify-content: center; min-height: 100vh; padding: 2rem 1rem; }
|
||||
.card { background: white; border-radius: 12px; border: 1px solid #e5e5e5; padding: 2rem; max-width: 520px; width: 100%; }
|
||||
h1 { font-size: 1.25rem; font-weight: 600; margin-bottom: 0.5rem; }
|
||||
p { font-size: 0.875rem; color: #666; line-height: 1.5; margin-bottom: 1rem; }
|
||||
.account { font-size: 0.875rem; color: #111; font-weight: 500; background: #f5f5f5; padding: 0.75rem 1rem; border-radius: 8px; margin-bottom: 1.5rem; }
|
||||
.permissions { font-size: 0.8125rem; color: #444; margin-bottom: 1.5rem; }
|
||||
.permissions li { margin-bottom: 0.25rem; }
|
||||
.actions { display: flex; gap: 0.75rem; }
|
||||
button { flex: 1; padding: 0.625rem 1rem; border-radius: 8px; font-size: 0.875rem; font-weight: 500; cursor: pointer; border: 1px solid #e5e5e5; }
|
||||
.scopes-header { font-size: 0.75rem; font-weight: 600; text-transform: uppercase; letter-spacing: 0.05em; color: #666; margin-bottom: 0.75rem; }
|
||||
.scopes-controls { display: flex; gap: 0.75rem; margin-bottom: 1rem; }
|
||||
.scopes-controls button { padding: 0.25rem 0.625rem; font-size: 0.75rem; font-weight: 500; background: white; color: #444; border: 1px solid #e5e5e5; border-radius: 6px; cursor: pointer; }
|
||||
.scopes-controls button:hover { background: #f5f5f5; }
|
||||
.scope-group { border: 1px solid #ececec; border-radius: 8px; padding: 0.75rem 1rem; margin-bottom: 0.5rem; }
|
||||
.scope-group-title { font-size: 0.8125rem; font-weight: 600; color: #111; margin-bottom: 0.5rem; }
|
||||
.scope-row { display: flex; gap: 0.625rem; padding: 0.375rem 0; align-items: flex-start; }
|
||||
.scope-row input { margin-top: 0.1875rem; cursor: pointer; }
|
||||
.scope-row label { font-size: 0.8125rem; color: #333; cursor: pointer; line-height: 1.4; }
|
||||
.scope-row .scope-name { font-weight: 500; color: #111; }
|
||||
.scope-row .scope-desc { color: #666; font-size: 0.75rem; display: block; margin-top: 0.125rem; }
|
||||
.scope-row.write .scope-name::after { content: " · skriv"; color: #b85c2c; font-weight: 500; }
|
||||
.warn { font-size: 0.75rem; color: #8b5a00; background: #fff7e6; border: 1px solid #f0d6a1; border-radius: 6px; padding: 0.625rem 0.75rem; margin: 1rem 0; line-height: 1.4; }
|
||||
.actions { display: flex; gap: 0.75rem; margin-top: 1.5rem; }
|
||||
.actions button { flex: 1; padding: 0.625rem 1rem; border-radius: 8px; font-size: 0.875rem; font-weight: 500; cursor: pointer; border: 1px solid #e5e5e5; }
|
||||
.allow { background: #111; color: white; border-color: #111; }
|
||||
.allow:hover { background: #333; }
|
||||
.deny { background: white; color: #111; }
|
||||
@@ -213,34 +247,73 @@ export async function GET(request: Request) {
|
||||
<body>
|
||||
<div class="card">
|
||||
<h1>Anslut MCP-klient</h1>
|
||||
<p>En extern applikation vill ansluta till ditt ${appNameLower}-konto.</p>
|
||||
<p>En extern applikation vill ansluta till ditt ${appNameLower}-konto. Välj vilka behörigheter du vill ge.</p>
|
||||
<div class="account">${escapeHtml(companyName)}</div>
|
||||
<ul class="permissions">
|
||||
<li>Visa och kategorisera transaktioner</li>
|
||||
<li>Skapa och visa fakturor</li>
|
||||
<li>Visa kunder och rapporter</li>
|
||||
<li>Skapa verifikationer</li>
|
||||
</ul>
|
||||
<div class="actions">
|
||||
<form method="POST" action="${url.pathname}${url.search}" style="flex:1;display:flex;">
|
||||
<input type="hidden" name="consent" value="deny">
|
||||
<input type="hidden" name="scope_binding" value="${escapeHtml(scopeBindingValue)}">
|
||||
<input type="hidden" name="scope_binding_sig" value="${escapeHtml(scopeBindingSignature)}">
|
||||
<button type="submit" class="deny" style="width:100%;">Neka</button>
|
||||
</form>
|
||||
<form method="POST" action="${url.pathname}${url.search}" style="flex:1;display:flex;">
|
||||
<input type="hidden" name="consent" value="allow">
|
||||
<input type="hidden" name="scope_binding" value="${escapeHtml(scopeBindingValue)}">
|
||||
<input type="hidden" name="scope_binding_sig" value="${escapeHtml(scopeBindingSignature)}">
|
||||
<button type="submit" class="allow" style="width:100%;">Tillåt</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<form method="POST" action="${url.pathname}${url.search}" id="consent-form">
|
||||
<input type="hidden" name="scope_binding" value="${escapeHtml(scopeBindingValue)}">
|
||||
<input type="hidden" name="scope_binding_sig" value="${escapeHtml(scopeBindingSignature)}">
|
||||
|
||||
<div class="scopes-header">Behörigheter</div>
|
||||
<div class="scopes-controls">
|
||||
<button type="button" id="select-read">Endast läs</button>
|
||||
<button type="button" id="select-all">Markera alla</button>
|
||||
<button type="button" id="select-none">Avmarkera alla</button>
|
||||
</div>
|
||||
|
||||
${scopeCheckboxesHtml}
|
||||
|
||||
<div class="warn">
|
||||
Skrivbehörigheter låter agenten stagea verifikationer, fakturor och löner. Alla skrivoperationer kräver din godkännande i ${appNameLower} innan de skrivs till databasen.
|
||||
</div>
|
||||
|
||||
<div class="actions">
|
||||
<button type="submit" name="consent" value="deny" class="deny">Neka</button>
|
||||
<button type="submit" name="consent" value="allow" class="allow">Tillåt</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<script nonce="${cspNonce}">
|
||||
(function() {
|
||||
var form = document.getElementById('consent-form');
|
||||
var boxes = form.querySelectorAll('input[name="scopes"]');
|
||||
function setAll(predicate) {
|
||||
boxes.forEach(function(b) { b.checked = predicate(b); });
|
||||
}
|
||||
document.getElementById('select-read').addEventListener('click', function() {
|
||||
setAll(function(b) { return b.dataset.kind === 'read'; });
|
||||
});
|
||||
document.getElementById('select-all').addEventListener('click', function() {
|
||||
setAll(function() { return true; });
|
||||
});
|
||||
document.getElementById('select-none').addEventListener('click', function() {
|
||||
setAll(function() { return false; });
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
</div>
|
||||
</body>
|
||||
</html>`
|
||||
|
||||
// script-src bound to the per-request nonce ensures the consent page's
|
||||
// inline JS can only be the block we actually emitted. Anything injected
|
||||
// by a forged response or persisted XSS would be blocked.
|
||||
const csp = [
|
||||
"default-src 'none'",
|
||||
`script-src 'nonce-${cspNonce}'`,
|
||||
"style-src 'unsafe-inline'",
|
||||
"form-action 'self'",
|
||||
"base-uri 'none'",
|
||||
"frame-ancestors 'none'",
|
||||
].join('; ')
|
||||
|
||||
return new Response(html, {
|
||||
headers: { 'Content-Type': 'text/html; charset=utf-8' },
|
||||
headers: {
|
||||
'Content-Type': 'text/html; charset=utf-8',
|
||||
'Content-Security-Policy': csp,
|
||||
'X-Content-Type-Options': 'nosniff',
|
||||
'Referrer-Policy': 'no-referrer',
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
@@ -286,8 +359,11 @@ export async function POST(request: Request) {
|
||||
}
|
||||
|
||||
// Verify the scope binding signed at consent display matches what was
|
||||
// submitted with the form. This prevents a tampered POST from widening the
|
||||
// grant beyond what the user actually saw (V10.3.1).
|
||||
// submitted with the form. This pins the form to the GET that minted it,
|
||||
// so an attacker who tricks the user into submitting a crafted form can't
|
||||
// change the client's `scope=` querystring midway through the flow
|
||||
// (V10.3.1). The granted scopes themselves come from the user's checkbox
|
||||
// selection and are bounded server-side by API_KEY_SCOPES.
|
||||
const presentedScopeBinding = formData.get('scope_binding')
|
||||
const presentedScopeBindingSig = formData.get('scope_binding_sig')
|
||||
const presentedScopeStr = typeof presentedScopeBinding === 'string' ? presentedScopeBinding : ''
|
||||
@@ -305,21 +381,44 @@ export async function POST(request: Request) {
|
||||
)
|
||||
}
|
||||
|
||||
// Parse the bound scope rather than re-reading the querystring at POST time
|
||||
// so the auth code always reflects the consent the user gave. parseRequestedScopes
|
||||
// already rejects requests where every scope is unknown (V10.2.6).
|
||||
// Validate the client's original scope request (rejects an entirely-unknown
|
||||
// scope set — V10.2.6). The actual grant comes from the user's checkbox
|
||||
// selection below, not from this querystring.
|
||||
const parsed = parseRequestedScopes(querystringScopeParam)
|
||||
if (parsed.kind === 'invalid_scope') {
|
||||
return errorRedirect(redirectUri, state, 'invalid_scope', parsed.description)
|
||||
}
|
||||
const requestedScopes = parsed.scopes
|
||||
|
||||
// The user selects scopes via checkboxes on the consent page. Two upper
|
||||
// bounds apply server-side, regardless of what the form posts:
|
||||
//
|
||||
// 1. validateScopes drops any value that isn't in API_KEY_SCOPES — guards
|
||||
// against forged values from a tampered POST.
|
||||
// 2. The grant must be a subset of what the client *originally asked for*
|
||||
// (the `scope` querystring on the GET). Otherwise a client that
|
||||
// requested only read scopes could end up with write grants because
|
||||
// the user ticked extra boxes — that's a least-privilege violation
|
||||
// (RFC 6749 §3.3, SOC 2 CC6.3, NIST AC-6) and removes the client's
|
||||
// ability to advertise the access surface it actually intends to use.
|
||||
//
|
||||
// When the client didn't pass a scope param at all (parsed.scopes is
|
||||
// undefined), the consent UI defaults to DEFAULT_OAUTH_SCOPES — that becomes
|
||||
// the implicit ceiling for the grant.
|
||||
const submittedScopes = formData.getAll('scopes').filter((s): s is string => typeof s === 'string')
|
||||
const validated = validateScopes(submittedScopes)
|
||||
const clientCeiling: ApiKeyScope[] = parsed.scopes ?? [...DEFAULT_OAUTH_SCOPES]
|
||||
const ceilingSet = new Set<ApiKeyScope>(clientCeiling)
|
||||
const boundedToClient = (validated ?? []).filter(s => ceilingSet.has(s))
|
||||
const grantedScopes: ApiKeyScope[] = boundedToClient.length > 0
|
||||
? boundedToClient
|
||||
: [...DEFAULT_OAUTH_SCOPES].filter(s => ceilingSet.has(s))
|
||||
|
||||
// Create auth code with userId (NO API key — that's created at /token after PKCE)
|
||||
const code = createAuthCode({
|
||||
userId: user.id,
|
||||
codeChallenge,
|
||||
redirectUri,
|
||||
...(requestedScopes ? { scopes: requestedScopes } : {}),
|
||||
scopes: grantedScopes,
|
||||
})
|
||||
|
||||
// Redirect to callback with the code
|
||||
@@ -333,6 +432,64 @@ export async function POST(request: Request) {
|
||||
return NextResponse.redirect(callbackUrl.toString(), 303)
|
||||
}
|
||||
|
||||
/**
|
||||
* Render the scope checkbox UI grouped by domain. Only scopes in `ceiling`
|
||||
* (the client's `scope` querystring, or DEFAULT_OAUTH_SCOPES) are surfaced —
|
||||
* scopes outside the ceiling are dropped from the consent UI so the user
|
||||
* can't tick boxes that the POST handler would refuse anyway. Pre-checks
|
||||
* every visible row by default.
|
||||
*/
|
||||
function renderScopeCheckboxes(
|
||||
preChecked: Set<ApiKeyScope>,
|
||||
ceiling: Set<ApiKeyScope>,
|
||||
): string {
|
||||
const renderedInGroups = new Set<ApiKeyScope>()
|
||||
const groups: string[] = []
|
||||
|
||||
for (const group of SCOPE_GROUPS) {
|
||||
const rows: string[] = []
|
||||
if (group.read && ceiling.has(group.read)) {
|
||||
rows.push(scopeRow(group.read, preChecked.has(group.read), 'read'))
|
||||
renderedInGroups.add(group.read)
|
||||
}
|
||||
if (group.write && ceiling.has(group.write)) {
|
||||
rows.push(scopeRow(group.write, preChecked.has(group.write), 'write'))
|
||||
renderedInGroups.add(group.write)
|
||||
}
|
||||
if (rows.length > 0) {
|
||||
groups.push(
|
||||
`<div class="scope-group"><div class="scope-group-title">${escapeHtml(group.label)}</div>${rows.join('')}</div>`
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
const remaining = ALL_SCOPES.filter(s => ceiling.has(s) && !renderedInGroups.has(s))
|
||||
if (remaining.length > 0) {
|
||||
const rows = remaining.map((s) =>
|
||||
scopeRow(s, preChecked.has(s), s.endsWith(':write') || s.endsWith(':manage') || s.endsWith(':approve') ? 'write' : 'read')
|
||||
)
|
||||
groups.push(
|
||||
`<div class="scope-group"><div class="scope-group-title">Övriga</div>${rows.join('')}</div>`
|
||||
)
|
||||
}
|
||||
|
||||
return groups.join('')
|
||||
}
|
||||
|
||||
function scopeRow(scope: ApiKeyScope, checked: boolean, kind: 'read' | 'write'): string {
|
||||
const meta = API_KEY_SCOPES[scope]
|
||||
const id = `scope-${scope.replace(/[^a-z0-9]/gi, '-')}`
|
||||
return `
|
||||
<div class="scope-row ${kind}">
|
||||
<input type="checkbox" id="${id}" name="scopes" value="${escapeHtml(scope)}" data-kind="${kind}" ${checked ? 'checked' : ''}>
|
||||
<label for="${id}">
|
||||
<span class="scope-name">${escapeHtml(meta.label)}</span>
|
||||
<span class="scope-desc">${escapeHtml(meta.description)}</span>
|
||||
</label>
|
||||
</div>
|
||||
`
|
||||
}
|
||||
|
||||
function escapeHtml(str: string): string {
|
||||
return str
|
||||
.replace(/&/g, '&')
|
||||
|
||||
@@ -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 })
|
||||
}
|
||||
|
||||
@@ -0,0 +1,150 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select'
|
||||
import { useCompany } from '@/contexts/CompanyContext'
|
||||
import type { CashAccount } from '@/types'
|
||||
|
||||
const STORAGE_KEY_PREFIX = 'gnubok:cash-account:'
|
||||
|
||||
interface Props {
|
||||
/**
|
||||
* Current selection — a BAS ledger account number ('1930', '1932', …).
|
||||
* `null` would only be meaningful if "all accounts" were an option, which
|
||||
* isn't currently supported (reconciliation is always single-account).
|
||||
*/
|
||||
value: string
|
||||
onChange: (accountNumber: string) => void
|
||||
/**
|
||||
* Optional label above the select. Pass null to render without a label.
|
||||
*/
|
||||
label?: string | null
|
||||
/**
|
||||
* Called once after the initial fetch completes so callers can suppress a
|
||||
* skeleton until the selector is ready.
|
||||
*/
|
||||
onReady?: () => void
|
||||
className?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Cash account selector for reconciliation, drift, and any UI that scopes a
|
||||
* read to a particular settlement account (1930 SEK, 1932 EUR, …).
|
||||
*
|
||||
* Loads /api/cash-accounts for the active company, persists the last selection
|
||||
* per company in sessionStorage, and renders the same Select primitive as the
|
||||
* fiscal-year picker so the UX stays consistent.
|
||||
*
|
||||
* sessionStorage (not localStorage) so the selection clears when the tab/
|
||||
* session ends. The data is a UI preference, not a credential; persisting
|
||||
* which BAS account a company uses across sessions in browser storage would
|
||||
* couple company id + financial account reference for the lifetime of the
|
||||
* browser profile (GDPR Art. 25(2) data minimisation, ISO 27001 A.8.12).
|
||||
*/
|
||||
export function CashAccountSelector({
|
||||
value,
|
||||
onChange,
|
||||
label = 'Konto',
|
||||
onReady,
|
||||
className,
|
||||
}: Props) {
|
||||
const { company } = useCompany()
|
||||
const [accounts, setAccounts] = useState<CashAccount[]>([])
|
||||
const [loaded, setLoaded] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (!company?.id) {
|
||||
onReady?.()
|
||||
return
|
||||
}
|
||||
let cancelled = false
|
||||
;(async () => {
|
||||
const res = await fetch('/api/cash-accounts')
|
||||
if (!res.ok) {
|
||||
if (!cancelled) {
|
||||
setLoaded(true)
|
||||
onReady?.()
|
||||
}
|
||||
return
|
||||
}
|
||||
const { data } = await res.json()
|
||||
if (cancelled) return
|
||||
|
||||
const fetched: CashAccount[] = data || []
|
||||
// is_primary first (already ordered on the server), then by ledger code.
|
||||
setAccounts(fetched)
|
||||
setLoaded(true)
|
||||
|
||||
// Restore last selection or pick the primary as default.
|
||||
if (typeof window !== 'undefined') {
|
||||
const stored = window.sessionStorage.getItem(STORAGE_KEY_PREFIX + company.id)
|
||||
const inFetched = (ledger: string) =>
|
||||
fetched.some(a => a.ledger_account === ledger)
|
||||
|
||||
if (stored && inFetched(stored)) {
|
||||
if (stored !== value) onChange(stored)
|
||||
} else {
|
||||
const primary = fetched.find(a => a.is_primary)
|
||||
const fallback = primary ?? fetched[0]
|
||||
if (fallback && fallback.ledger_account !== value) {
|
||||
onChange(fallback.ledger_account)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
onReady?.()
|
||||
})()
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
// onReady excluded — lifecycle callback, shouldn't retrigger on parent renders.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [company?.id])
|
||||
|
||||
const handleChange = (next: string) => {
|
||||
if (company?.id && typeof window !== 'undefined') {
|
||||
window.sessionStorage.setItem(STORAGE_KEY_PREFIX + company.id, next)
|
||||
}
|
||||
onChange(next)
|
||||
}
|
||||
|
||||
// Fallback when the table is empty (fresh company, no PSD2 connections yet):
|
||||
// show a single hardcoded '1930' option so the rest of the UI still works.
|
||||
const options = accounts.length > 0
|
||||
? accounts.map(a => ({
|
||||
value: a.ledger_account,
|
||||
label: `${a.ledger_account} ${a.name ?? a.iban ?? a.currency}`,
|
||||
}))
|
||||
: [{ value: '1930', label: '1930 Bankkonto' }]
|
||||
|
||||
return (
|
||||
<div className={className}>
|
||||
{label && <Label>{label}</Label>}
|
||||
<div className={`flex items-center gap-2 ${label ? 'mt-1' : ''}`}>
|
||||
<Select
|
||||
value={value}
|
||||
onValueChange={handleChange}
|
||||
disabled={!loaded && accounts.length === 0}
|
||||
>
|
||||
<SelectTrigger className="w-full sm:w-[280px]">
|
||||
<SelectValue placeholder={loaded ? 'Välj konto' : 'Laddar…'} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{options.map(o => (
|
||||
<SelectItem key={o.value} value={o.value}>
|
||||
{o.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import { Badge } from '@/components/ui/badge'
|
||||
import { AccountNumber } from '@/components/ui/account-number'
|
||||
import { AlertCircle, ChevronDown, ChevronRight, Link2, Unlink, Play, Eye } from 'lucide-react'
|
||||
import { formatCurrency, formatDate } from '@/lib/utils'
|
||||
import { CashAccountSelector } from '@/components/common/CashAccountSelector'
|
||||
|
||||
function formatAmount(amount: number): string {
|
||||
return amount.toLocaleString('sv-SE', { minimumFractionDigits: 2, maximumFractionDigits: 2 })
|
||||
@@ -100,6 +101,7 @@ export function BankReconciliationView() {
|
||||
|
||||
const [dateFrom, setDateFrom] = useState('')
|
||||
const [dateTo, setDateTo] = useState('')
|
||||
const [accountNumber, setAccountNumber] = useState('1930')
|
||||
|
||||
const [dryRunResults, setDryRunResults] = useState<DryRunMatch[] | null>(null)
|
||||
const [runLoading, setRunLoading] = useState(false)
|
||||
@@ -117,7 +119,8 @@ export function BankReconciliationView() {
|
||||
const params = new URLSearchParams()
|
||||
if (dateFrom) params.set('date_from', dateFrom)
|
||||
if (dateTo) params.set('date_to', dateTo)
|
||||
const qs = params.toString() ? `?${params}` : ''
|
||||
params.set('account_number', accountNumber)
|
||||
const qs = `?${params}`
|
||||
|
||||
const [statusRes, glRes, unmatchedRes, matchedRes] = await Promise.all([
|
||||
fetch(`/api/reconciliation/bank/status${qs}`),
|
||||
@@ -143,7 +146,7 @@ export function BankReconciliationView() {
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [dateFrom, dateTo])
|
||||
}, [dateFrom, dateTo, accountNumber])
|
||||
|
||||
useEffect(() => {
|
||||
fetchAll()
|
||||
@@ -159,6 +162,7 @@ export function BankReconciliationView() {
|
||||
body: JSON.stringify({
|
||||
date_from: dateFrom || undefined,
|
||||
date_to: dateTo || undefined,
|
||||
account_number: accountNumber,
|
||||
dry_run: true,
|
||||
}),
|
||||
})
|
||||
@@ -182,6 +186,7 @@ export function BankReconciliationView() {
|
||||
body: JSON.stringify({
|
||||
date_from: dateFrom || undefined,
|
||||
date_to: dateTo || undefined,
|
||||
account_number: accountNumber,
|
||||
dry_run: false,
|
||||
}),
|
||||
})
|
||||
@@ -337,6 +342,10 @@ export function BankReconciliationView() {
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<div className="flex flex-wrap items-end gap-4">
|
||||
<CashAccountSelector
|
||||
value={accountNumber}
|
||||
onChange={setAccountNumber}
|
||||
/>
|
||||
<div>
|
||||
<Label>Datum från</Label>
|
||||
<input
|
||||
|
||||
@@ -627,6 +627,34 @@ export const enableBankingExtension: Extension = {
|
||||
return NextResponse.json({ error: 'Kunde inte spara kontoval' }, { status: 500 })
|
||||
}
|
||||
|
||||
// Mirror the user's selection into cash_accounts so routing decisions
|
||||
// and reconciliation pick up the new enabled state + ledger mapping
|
||||
// without reading the JSONB column.
|
||||
{
|
||||
const { upsertFromPsd2 } = await import('@/lib/cash-accounts/service')
|
||||
for (const a of updatedAccounts) {
|
||||
try {
|
||||
await upsertFromPsd2(supabase, companyId, {
|
||||
bank_connection_id: connection.id,
|
||||
external_uid: a.uid,
|
||||
currency: a.currency,
|
||||
ledger_account: a.ledger_account ?? '1930',
|
||||
iban: a.iban ?? null,
|
||||
name: a.name ?? null,
|
||||
balance: a.balance ?? null,
|
||||
balance_updated_at: a.balance_updated_at ?? null,
|
||||
enabled: a.enabled ?? true,
|
||||
})
|
||||
} catch (cashErr) {
|
||||
log.error('[enable-banking] Failed to mirror cash_account on selection save', {
|
||||
connectionId: connection.id,
|
||||
uid: a.uid,
|
||||
error: cashErr instanceof Error ? cashErr.message : String(cashErr),
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const newStatus = updatePayload.status ?? connection.status
|
||||
log.info('[enable-banking] Account selection saved', {
|
||||
connectionId: connection.id,
|
||||
|
||||
@@ -99,19 +99,28 @@ export async function syncAccountTransactions(
|
||||
|
||||
const bankTransactions = transactions.map(tx => convertTransaction(tx, account.currency))
|
||||
|
||||
// Convert Enable Banking format to generic RawTransaction
|
||||
const rawTransactions: RawTransaction[] = bankTransactions.map((tx) => ({
|
||||
date: tx.booking_date || tx.date,
|
||||
description: tx.description || tx.counterparty_name || 'Unknown',
|
||||
amount: tx.amount,
|
||||
currency: tx.currency || account.currency,
|
||||
external_id: `eb_${account.iban || account.uid}_${tx.id}`,
|
||||
mcc_code: tx.merchant_category_code ? parseInt(tx.merchant_category_code, 10) : null,
|
||||
merchant_name: tx.counterparty_name || null,
|
||||
reference: tx.reference || null,
|
||||
bank_connection_id: connectionId,
|
||||
import_source: 'enable_banking',
|
||||
}))
|
||||
// Convert Enable Banking format to generic RawTransaction. counterparty
|
||||
// identification: prefer IBAN (international, normalized) over BBAN/BG
|
||||
// numbers — the own-account detector matches on IBAN first, falling back
|
||||
// to counterparty_account for Swedish domestic transfers.
|
||||
const rawTransactions: RawTransaction[] = bankTransactions.map((tx) => {
|
||||
const cpAccount = tx.counterparty_account ?? null
|
||||
const looksLikeIban = cpAccount && /^[A-Z]{2}\d/.test(cpAccount.replace(/\s+/g, ''))
|
||||
return {
|
||||
date: tx.booking_date || tx.date,
|
||||
description: tx.description || tx.counterparty_name || 'Unknown',
|
||||
amount: tx.amount,
|
||||
currency: tx.currency || account.currency,
|
||||
external_id: `eb_${account.iban || account.uid}_${tx.id}`,
|
||||
mcc_code: tx.merchant_category_code ? parseInt(tx.merchant_category_code, 10) : null,
|
||||
merchant_name: tx.counterparty_name || null,
|
||||
reference: tx.reference || null,
|
||||
bank_connection_id: connectionId,
|
||||
import_source: 'enable_banking',
|
||||
counterparty_iban: looksLikeIban ? cpAccount!.replace(/\s+/g, '') : null,
|
||||
counterparty_account: !looksLikeIban ? cpAccount : null,
|
||||
}
|
||||
})
|
||||
|
||||
const ingestOptions: IngestOptions = {}
|
||||
if (syncOptions?.skipAutoCategorization) ingestOptions.skipAutoCategorization = true
|
||||
|
||||
@@ -1,68 +1,241 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import type { SupabaseClient } from '@supabase/supabase-js'
|
||||
import { createQueuedMockSupabase } from '@/tests/helpers'
|
||||
import { guessCounterAccount } from '../lib/skattekonto-booking'
|
||||
|
||||
/**
|
||||
* System seeds mirror supabase/migrations/20260519100000_skattekonto_rules.sql.
|
||||
* Kept in lockstep so the resolver behaves identically against mock and real DB.
|
||||
*/
|
||||
const SEED_RULES = [
|
||||
{
|
||||
id: 'sys-1', priority: 10, pattern: 'inbetalning bokförd,inbetalning,överföring från bank',
|
||||
amount_min: null, amount_max: null, company_type: 'all',
|
||||
counter_account: '__PRIMARY_SEK__', counter_account_ef: null,
|
||||
label: 'Inbetalning till skattekonto', active: true,
|
||||
},
|
||||
{
|
||||
id: 'sys-2', priority: 10, pattern: 'utbetalning,återbetalning',
|
||||
amount_min: null, amount_max: null, company_type: 'all',
|
||||
counter_account: '__PRIMARY_SEK__', counter_account_ef: null,
|
||||
label: 'Utbetalning från skattekonto', active: true,
|
||||
},
|
||||
{
|
||||
id: 'sys-3', priority: 20, pattern: 'debiterad preliminärskatt,preliminärskatt,f-skatt,fskatt',
|
||||
amount_min: null, amount_max: null, company_type: 'all',
|
||||
counter_account: '2510', counter_account_ef: '2012',
|
||||
label: 'Preliminär skatt', active: true,
|
||||
},
|
||||
{
|
||||
id: 'sys-4', priority: 20, pattern: 'arbetsgivaravgift,sociala avgifter,agi',
|
||||
amount_min: null, amount_max: null, company_type: 'all',
|
||||
counter_account: '2730', counter_account_ef: null,
|
||||
label: 'Arbetsgivaravgifter', active: true,
|
||||
},
|
||||
{
|
||||
id: 'sys-5', priority: 20, pattern: 'avdragen skatt,personalskatt,a-skatt',
|
||||
amount_min: null, amount_max: null, company_type: 'all',
|
||||
counter_account: '2710', counter_account_ef: null,
|
||||
label: 'Avdragen skatt anställda', active: true,
|
||||
},
|
||||
{
|
||||
id: 'sys-6', priority: 20, pattern: 'mervärdesskatt,moms,momsdeklaration',
|
||||
amount_min: null, amount_max: null, company_type: 'all',
|
||||
counter_account: '2650', counter_account_ef: null,
|
||||
label: 'Redovisningskonto för moms', active: true,
|
||||
},
|
||||
{
|
||||
id: 'sys-7', priority: 25, pattern: 'skattetillägg,förseningsavgift',
|
||||
amount_min: null, amount_max: null, company_type: 'all',
|
||||
counter_account: '6992', counter_account_ef: null,
|
||||
label: 'Ej avdragsgilla skatteavgifter', active: true,
|
||||
},
|
||||
{
|
||||
id: 'sys-8', priority: 30, pattern: 'kostnadsränta',
|
||||
amount_min: null, amount_max: null, company_type: 'all',
|
||||
counter_account: '8423', counter_account_ef: null,
|
||||
label: 'Kostnadsränta skattekonto', active: true,
|
||||
},
|
||||
{
|
||||
id: 'sys-9', priority: 30, pattern: 'intäktsränta',
|
||||
amount_min: null, amount_max: null, company_type: 'all',
|
||||
counter_account: '8314', counter_account_ef: null,
|
||||
label: 'Intäktsränta skattekonto', active: true,
|
||||
},
|
||||
]
|
||||
|
||||
function makeSupabase() {
|
||||
return createQueuedMockSupabase()
|
||||
}
|
||||
|
||||
/**
|
||||
* Queue setup helper. Each call to guessCounterAccount makes:
|
||||
* 1. skattekonto_rules query (always)
|
||||
* 2. cash_accounts query (only when a __PRIMARY_SEK__ rule matches)
|
||||
*
|
||||
* primarySekRow lets a test stub a cash_accounts.getPrimary result so the
|
||||
* sentinel resolves to that row's ledger_account. When omitted, the fallback
|
||||
* '1930' is used (sentinel hit but no row in cash_accounts).
|
||||
*/
|
||||
function enqueueRules(
|
||||
enqueue: ReturnType<typeof createQueuedMockSupabase>['enqueue'],
|
||||
rules = SEED_RULES,
|
||||
primarySekRow: { ledger_account: string } | null = null,
|
||||
) {
|
||||
enqueue({ data: rules })
|
||||
enqueue({ data: primarySekRow }) // cash_accounts.maybeSingle()
|
||||
// anyPrimary fallback inside getPrimary triggers only when first lookup is null
|
||||
if (!primarySekRow) enqueue({ data: null })
|
||||
}
|
||||
|
||||
describe('guessCounterAccount', () => {
|
||||
it('routes "Inbetalning bokförd" to bank account 1930', () => {
|
||||
const guess = guessCounterAccount('Inbetalning bokförd 240412', 'aktiebolag')
|
||||
it('routes "Inbetalning bokförd" via __PRIMARY_SEK__ sentinel to 1930 fallback', async () => {
|
||||
const { supabase, enqueue } = makeSupabase()
|
||||
enqueueRules(enqueue)
|
||||
const guess = await guessCounterAccount(
|
||||
supabase as unknown as SupabaseClient, 'company-1', 'Inbetalning bokförd 240412', 'aktiebolag',
|
||||
)
|
||||
expect(guess?.account).toBe('1930')
|
||||
})
|
||||
|
||||
it('routes refund-style descriptions to 1930', () => {
|
||||
expect(guessCounterAccount('Utbetalning 1234', 'aktiebolag')?.account).toBe('1930')
|
||||
expect(guessCounterAccount('Återbetalning av moms', 'aktiebolag')?.account).toBe('1930')
|
||||
it('resolves __PRIMARY_SEK__ to the cash_accounts.is_primary row when present', async () => {
|
||||
const { supabase, enqueue } = makeSupabase()
|
||||
enqueueRules(enqueue, SEED_RULES, { ledger_account: '1932' })
|
||||
const guess = await guessCounterAccount(
|
||||
supabase as unknown as SupabaseClient, 'company-1', 'Inbetalning bokförd', 'aktiebolag',
|
||||
)
|
||||
expect(guess?.account).toBe('1932')
|
||||
})
|
||||
|
||||
it('uses 2510 for AB preliminär skatt and 2012 for EF', () => {
|
||||
it('routes refund-style descriptions via __PRIMARY_SEK__ sentinel', async () => {
|
||||
const { supabase, enqueue } = makeSupabase()
|
||||
enqueueRules(enqueue)
|
||||
expect(
|
||||
guessCounterAccount('Debiterad preliminärskatt', 'aktiebolag')?.account,
|
||||
(await guessCounterAccount(supabase as unknown as SupabaseClient, 'company-1', 'Utbetalning 1234', 'aktiebolag'))?.account,
|
||||
).toBe('1930')
|
||||
|
||||
enqueueRules(enqueue)
|
||||
expect(
|
||||
(await guessCounterAccount(supabase as unknown as SupabaseClient, 'company-1', 'Återbetalning av moms', 'aktiebolag'))?.account,
|
||||
).toBe('1930')
|
||||
})
|
||||
|
||||
it('uses 2510 for AB preliminär skatt and 2012 for EF', async () => {
|
||||
const { supabase, enqueue } = makeSupabase()
|
||||
enqueue({ data: SEED_RULES })
|
||||
expect(
|
||||
(await guessCounterAccount(supabase as unknown as SupabaseClient, 'company-1', 'Debiterad preliminärskatt', 'aktiebolag'))?.account,
|
||||
).toBe('2510')
|
||||
|
||||
enqueue({ data: SEED_RULES })
|
||||
expect(
|
||||
guessCounterAccount('Debiterad preliminärskatt', 'enskild_firma')?.account,
|
||||
(await guessCounterAccount(supabase as unknown as SupabaseClient, 'company-1', 'Debiterad preliminärskatt', 'enskild_firma'))?.account,
|
||||
).toBe('2012')
|
||||
})
|
||||
|
||||
it('routes employer payroll taxes to 2731', () => {
|
||||
it('routes employer payroll taxes to 2730 (clearing/redovisningskonto, not 2731 accrual)', async () => {
|
||||
const { supabase, enqueue } = makeSupabase()
|
||||
enqueue({ data: SEED_RULES })
|
||||
expect(
|
||||
guessCounterAccount('Arbetsgivaravgifter januari', 'aktiebolag')?.account,
|
||||
).toBe('2731')
|
||||
(await guessCounterAccount(supabase as unknown as SupabaseClient, 'company-1', 'Arbetsgivaravgifter januari', 'aktiebolag'))?.account,
|
||||
).toBe('2730')
|
||||
|
||||
enqueue({ data: SEED_RULES })
|
||||
expect(
|
||||
guessCounterAccount('Sociala avgifter Q1', 'aktiebolag')?.account,
|
||||
).toBe('2731')
|
||||
(await guessCounterAccount(supabase as unknown as SupabaseClient, 'company-1', 'Sociala avgifter Q1', 'aktiebolag'))?.account,
|
||||
).toBe('2730')
|
||||
})
|
||||
|
||||
it('routes deducted income tax to 2710', () => {
|
||||
it('routes deducted income tax to 2710', async () => {
|
||||
const { supabase, enqueue } = makeSupabase()
|
||||
enqueue({ data: SEED_RULES })
|
||||
expect(
|
||||
guessCounterAccount('Avdragen skatt anställda', 'aktiebolag')?.account,
|
||||
(await guessCounterAccount(supabase as unknown as SupabaseClient, 'company-1', 'Avdragen skatt anställda', 'aktiebolag'))?.account,
|
||||
).toBe('2710')
|
||||
})
|
||||
|
||||
it('routes VAT settlements to 2650', () => {
|
||||
it('routes VAT settlements to 2650', async () => {
|
||||
const { supabase, enqueue } = makeSupabase()
|
||||
enqueue({ data: SEED_RULES })
|
||||
expect(
|
||||
guessCounterAccount('Mervärdesskatt mars', 'aktiebolag')?.account,
|
||||
(await guessCounterAccount(supabase as unknown as SupabaseClient, 'company-1', 'Mervärdesskatt mars', 'aktiebolag'))?.account,
|
||||
).toBe('2650')
|
||||
|
||||
enqueue({ data: SEED_RULES })
|
||||
expect(
|
||||
(await guessCounterAccount(supabase as unknown as SupabaseClient, 'company-1', 'Moms Q1 2025', 'aktiebolag'))?.account,
|
||||
).toBe('2650')
|
||||
expect(guessCounterAccount('Moms Q1 2025', 'aktiebolag')?.account).toBe(
|
||||
'2650',
|
||||
)
|
||||
})
|
||||
|
||||
it('routes interest to 8423/8313', () => {
|
||||
it('routes interest to 8423 (kostnadsränta) and 8314 (skattefri intäktsränta)', async () => {
|
||||
const { supabase, enqueue } = makeSupabase()
|
||||
enqueue({ data: SEED_RULES })
|
||||
expect(
|
||||
guessCounterAccount('Kostnadsränta skattekonto', 'aktiebolag')?.account,
|
||||
(await guessCounterAccount(supabase as unknown as SupabaseClient, 'company-1', 'Kostnadsränta skattekonto', 'aktiebolag'))?.account,
|
||||
).toBe('8423')
|
||||
|
||||
enqueue({ data: SEED_RULES })
|
||||
// Skattekontoräntan är skattefri per IL 8 kap 7 § — 8314, inte 8313.
|
||||
expect(
|
||||
guessCounterAccount('Intäktsränta skattekonto', 'aktiebolag')?.account,
|
||||
).toBe('8313')
|
||||
(await guessCounterAccount(supabase as unknown as SupabaseClient, 'company-1', 'Intäktsränta skattekonto', 'aktiebolag'))?.account,
|
||||
).toBe('8314')
|
||||
})
|
||||
|
||||
it('returns null when no keyword matches', () => {
|
||||
it('routes skattetillägg and förseningsavgift to 6992 (non-deductible)', async () => {
|
||||
const { supabase, enqueue } = makeSupabase()
|
||||
enqueue({ data: SEED_RULES })
|
||||
expect(
|
||||
guessCounterAccount('Något konstigt vi inte känner igen', 'aktiebolag'),
|
||||
(await guessCounterAccount(supabase as unknown as SupabaseClient, 'company-1', 'Skattetillägg 20%', 'aktiebolag'))?.account,
|
||||
).toBe('6992')
|
||||
|
||||
enqueue({ data: SEED_RULES })
|
||||
// Förseningsavgift contains the substring "förseningsavgift". The "moms" suffix
|
||||
// would also match a lower-priority rule (2650), but priority 25 (penalty)
|
||||
// beats priority 20 (moms) — penalty routing wins.
|
||||
expect(
|
||||
(await guessCounterAccount(supabase as unknown as SupabaseClient, 'company-1', 'Förseningsavgift arbetsgivardeklaration', 'aktiebolag'))?.account,
|
||||
).toBe('6992')
|
||||
})
|
||||
|
||||
it('does NOT route plain omprövning to 6992 — underlying tax rules win', async () => {
|
||||
const { supabase, enqueue } = makeSupabase()
|
||||
enqueue({ data: SEED_RULES })
|
||||
// "Omprövning av momsdeklaration" should route to moms (2650), because
|
||||
// omprövning is a re-assessment of the underlying tax, not a penalty.
|
||||
expect(
|
||||
(await guessCounterAccount(supabase as unknown as SupabaseClient, 'company-1', 'Omprövning av momsdeklaration', 'aktiebolag'))?.account,
|
||||
).toBe('2650')
|
||||
})
|
||||
|
||||
it('returns null for anstånd — SKV-side deferral, GL does not move', async () => {
|
||||
const { supabase, enqueue } = makeSupabase()
|
||||
enqueue({ data: SEED_RULES })
|
||||
expect(
|
||||
await guessCounterAccount(supabase as unknown as SupabaseClient, 'company-1', 'Anstånd med skattebetalning', 'aktiebolag'),
|
||||
).toBeNull()
|
||||
})
|
||||
|
||||
it('matches case-insensitively', () => {
|
||||
it('returns null when no keyword matches', async () => {
|
||||
const { supabase, enqueue } = makeSupabase()
|
||||
enqueue({ data: SEED_RULES })
|
||||
expect(
|
||||
guessCounterAccount('INBETALNING BOKFÖRD 240412', 'aktiebolag')?.account,
|
||||
await guessCounterAccount(supabase as unknown as SupabaseClient, 'company-1', 'Något konstigt vi inte känner igen', 'aktiebolag'),
|
||||
).toBeNull()
|
||||
})
|
||||
|
||||
it('matches case-insensitively (via __PRIMARY_SEK__ sentinel)', async () => {
|
||||
const { supabase, enqueue } = makeSupabase()
|
||||
enqueueRules(enqueue)
|
||||
expect(
|
||||
(await guessCounterAccount(supabase as unknown as SupabaseClient, 'company-1', 'INBETALNING BOKFÖRD 240412', 'aktiebolag'))?.account,
|
||||
).toBe('1930')
|
||||
})
|
||||
|
||||
it('returns null when the rules table is empty', async () => {
|
||||
const { supabase, enqueue } = makeSupabase()
|
||||
enqueue({ data: [] })
|
||||
expect(
|
||||
await guessCounterAccount(supabase as unknown as SupabaseClient, 'company-1', 'Inbetalning bokförd', 'aktiebolag'),
|
||||
).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -0,0 +1,204 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { createQueuedMockSupabase } from '@/tests/helpers'
|
||||
import {
|
||||
computeSkattekontoDrift,
|
||||
maybeAlertDrift,
|
||||
} from '../lib/skattekonto-drift'
|
||||
|
||||
function fakeCtx(overrides: {
|
||||
supabase: ReturnType<typeof createQueuedMockSupabase>['supabase']
|
||||
settings: { get: ReturnType<typeof vi.fn>; set: ReturnType<typeof vi.fn>; clear?: ReturnType<typeof vi.fn> }
|
||||
emit?: ReturnType<typeof vi.fn>
|
||||
}) {
|
||||
return {
|
||||
userId: 'user-1',
|
||||
companyId: 'company-1',
|
||||
extensionId: 'skatteverket',
|
||||
supabase: overrides.supabase,
|
||||
emit: overrides.emit ?? vi.fn().mockResolvedValue(undefined),
|
||||
settings: {
|
||||
get: overrides.settings.get,
|
||||
set: overrides.settings.set,
|
||||
clear: overrides.settings.clear ?? vi.fn(),
|
||||
},
|
||||
storage: {} as unknown,
|
||||
log: { info: vi.fn(), warn: vi.fn(), error: vi.fn() } as unknown,
|
||||
services: {} as unknown,
|
||||
} as unknown as Parameters<typeof computeSkattekontoDrift>[0]
|
||||
}
|
||||
|
||||
describe('computeSkattekontoDrift', () => {
|
||||
beforeEach(() => vi.clearAllMocks())
|
||||
|
||||
it('returns null when no snapshot has been cached', async () => {
|
||||
const { supabase } = createQueuedMockSupabase()
|
||||
const ctx = fakeCtx({
|
||||
supabase,
|
||||
settings: { get: vi.fn().mockResolvedValue(null), set: vi.fn() },
|
||||
})
|
||||
|
||||
const drift = await computeSkattekontoDrift(ctx)
|
||||
expect(drift).toBeNull()
|
||||
})
|
||||
|
||||
it('computes drift = saldoSkatteverket - GL 1630 sum (positive when SKV ahead)', async () => {
|
||||
const { supabase, enqueue } = createQueuedMockSupabase()
|
||||
// GL 1630 query: 5000 SEK debit
|
||||
enqueue({
|
||||
data: [{ debit_amount: 5000, credit_amount: 0 }],
|
||||
})
|
||||
// Unbooked rows query
|
||||
enqueue({ data: [] })
|
||||
|
||||
const ctx = fakeCtx({
|
||||
supabase,
|
||||
settings: {
|
||||
get: vi.fn().mockImplementation((key: string) => {
|
||||
if (key === 'skattekonto_balance_snapshot') {
|
||||
return Promise.resolve({
|
||||
saldo: { saldoSkatteverket: 5500, saldoKronofogden: 0 },
|
||||
fetchedAt: new Date('2026-06-12T04:00:00Z').getTime(),
|
||||
})
|
||||
}
|
||||
return Promise.resolve(null)
|
||||
}),
|
||||
set: vi.fn(),
|
||||
},
|
||||
})
|
||||
|
||||
const drift = await computeSkattekontoDrift(ctx)
|
||||
expect(drift).not.toBeNull()
|
||||
expect(drift!.saldoSkatteverket).toBe(5500)
|
||||
expect(drift!.glSum1630).toBe(5000)
|
||||
expect(drift!.drift).toBe(500)
|
||||
expect(drift!.tolerance).toBe(1)
|
||||
})
|
||||
|
||||
it('honors a per-company override of the tolerance', async () => {
|
||||
const { supabase, enqueue } = createQueuedMockSupabase()
|
||||
enqueue({ data: [{ debit_amount: 1000, credit_amount: 0 }] })
|
||||
enqueue({ data: [] })
|
||||
|
||||
const ctx = fakeCtx({
|
||||
supabase,
|
||||
settings: {
|
||||
get: vi.fn().mockImplementation((key: string) => {
|
||||
if (key === 'skattekonto_balance_snapshot') {
|
||||
return Promise.resolve({
|
||||
saldo: { saldoSkatteverket: 1000.5, saldoKronofogden: 0 },
|
||||
fetchedAt: Date.now(),
|
||||
})
|
||||
}
|
||||
if (key === 'skattekonto_drift_tolerance') return Promise.resolve(100)
|
||||
return Promise.resolve(null)
|
||||
}),
|
||||
set: vi.fn(),
|
||||
},
|
||||
})
|
||||
|
||||
const drift = await computeSkattekontoDrift(ctx)
|
||||
expect(drift!.tolerance).toBe(100)
|
||||
})
|
||||
})
|
||||
|
||||
describe('maybeAlertDrift', () => {
|
||||
it('does NOT emit when |drift| <= tolerance', async () => {
|
||||
const { supabase } = createQueuedMockSupabase()
|
||||
const emit = vi.fn().mockResolvedValue(undefined)
|
||||
const ctx = fakeCtx({
|
||||
supabase,
|
||||
settings: { get: vi.fn().mockResolvedValue(null), set: vi.fn() },
|
||||
emit,
|
||||
})
|
||||
const alerted = await maybeAlertDrift(ctx, {
|
||||
saldoSkatteverket: 100,
|
||||
glSum1630: 100.5,
|
||||
drift: -0.5,
|
||||
fetchedAt: Date.now(),
|
||||
tolerance: 1,
|
||||
unbookedRows: [],
|
||||
})
|
||||
expect(alerted).toBe(false)
|
||||
expect(emit).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('emits skattekonto.drift_detected on a fresh drift', async () => {
|
||||
const { supabase } = createQueuedMockSupabase()
|
||||
const emit = vi.fn().mockResolvedValue(undefined)
|
||||
const setSpy = vi.fn().mockResolvedValue(undefined)
|
||||
const ctx = fakeCtx({
|
||||
supabase,
|
||||
settings: { get: vi.fn().mockResolvedValue(null), set: setSpy },
|
||||
emit,
|
||||
})
|
||||
const alerted = await maybeAlertDrift(ctx, {
|
||||
saldoSkatteverket: 5000,
|
||||
glSum1630: 4000,
|
||||
drift: 1000,
|
||||
fetchedAt: Date.now(),
|
||||
tolerance: 1,
|
||||
unbookedRows: [],
|
||||
})
|
||||
expect(alerted).toBe(true)
|
||||
expect(emit).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ type: 'skattekonto.drift_detected' }),
|
||||
)
|
||||
expect(setSpy).toHaveBeenCalledWith(
|
||||
'skattekonto_drift_last_alert_at',
|
||||
expect.objectContaining({ lastSign: 1 }),
|
||||
)
|
||||
})
|
||||
|
||||
it('suppresses repeat alerts within the 24h throttle when sign is unchanged', async () => {
|
||||
const { supabase } = createQueuedMockSupabase()
|
||||
const emit = vi.fn().mockResolvedValue(undefined)
|
||||
const now = Date.now()
|
||||
const ctx = fakeCtx({
|
||||
supabase,
|
||||
settings: {
|
||||
get: vi.fn().mockResolvedValue({
|
||||
lastAlertAt: now - 60 * 60 * 1000, // 1h ago
|
||||
lastSign: 1,
|
||||
}),
|
||||
set: vi.fn(),
|
||||
},
|
||||
emit,
|
||||
})
|
||||
const alerted = await maybeAlertDrift(ctx, {
|
||||
saldoSkatteverket: 5000,
|
||||
glSum1630: 4000,
|
||||
drift: 1000,
|
||||
fetchedAt: now,
|
||||
tolerance: 1,
|
||||
unbookedRows: [],
|
||||
})
|
||||
expect(alerted).toBe(false)
|
||||
expect(emit).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('re-alerts when the sign flips even within the throttle window', async () => {
|
||||
const { supabase } = createQueuedMockSupabase()
|
||||
const emit = vi.fn().mockResolvedValue(undefined)
|
||||
const ctx = fakeCtx({
|
||||
supabase,
|
||||
settings: {
|
||||
get: vi.fn().mockResolvedValue({
|
||||
lastAlertAt: Date.now() - 60 * 60 * 1000,
|
||||
lastSign: 1,
|
||||
}),
|
||||
set: vi.fn(),
|
||||
},
|
||||
emit,
|
||||
})
|
||||
const alerted = await maybeAlertDrift(ctx, {
|
||||
saldoSkatteverket: 3000,
|
||||
glSum1630: 4000,
|
||||
drift: -1000,
|
||||
fetchedAt: Date.now(),
|
||||
tolerance: 1,
|
||||
unbookedRows: [],
|
||||
})
|
||||
expect(alerted).toBe(true)
|
||||
expect(emit).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,265 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { createQueuedMockSupabase } from '@/tests/helpers'
|
||||
import {
|
||||
findMatchCandidates,
|
||||
findMatchSuggestionsBulk,
|
||||
parseAgiPeriod,
|
||||
} from '../lib/skattekonto-match'
|
||||
|
||||
const COMPANY = 'company-1'
|
||||
|
||||
describe('parseAgiPeriod', () => {
|
||||
it('extracts year and month from "Arbetsgivardeklaration YYYYMM"', () => {
|
||||
expect(parseAgiPeriod('Arbetsgivardeklaration 202605')).toEqual({
|
||||
year: 2026,
|
||||
month: 5,
|
||||
})
|
||||
})
|
||||
|
||||
it('accepts the dash variant "YYYY-MM"', () => {
|
||||
expect(parseAgiPeriod('arbetsgivardeklaration 2026-05')).toEqual({
|
||||
year: 2026,
|
||||
month: 5,
|
||||
})
|
||||
})
|
||||
|
||||
it('is case-insensitive and tolerates leading whitespace', () => {
|
||||
expect(parseAgiPeriod(' ARBETSGIVARDEKLARATION 202611')).toEqual({
|
||||
year: 2026,
|
||||
month: 11,
|
||||
})
|
||||
})
|
||||
|
||||
it('falls back to a numeric YYYYMM after any AGI-adjacent keyword', () => {
|
||||
expect(parseAgiPeriod('AGI 202607 inbetalning')).toEqual({
|
||||
year: 2026,
|
||||
month: 7,
|
||||
})
|
||||
expect(parseAgiPeriod('Arbetsgivaravgift januari 202601')).toEqual({
|
||||
year: 2026,
|
||||
month: 1,
|
||||
})
|
||||
})
|
||||
|
||||
it('returns null when the period token is missing', () => {
|
||||
// AGI keyword present, no period digits
|
||||
expect(parseAgiPeriod('Arbetsgivardeklaration')).toBeNull()
|
||||
})
|
||||
|
||||
it('returns null when there is no AGI keyword at all', () => {
|
||||
expect(parseAgiPeriod('Inbetalning bokförd 240412')).toBeNull()
|
||||
// OCR-style numerics on a moms row should not match.
|
||||
expect(parseAgiPeriod('Moms 202605')).toBeNull()
|
||||
})
|
||||
|
||||
it('rejects months outside 1-12', () => {
|
||||
expect(parseAgiPeriod('Arbetsgivardeklaration 202613')).toBeNull()
|
||||
expect(parseAgiPeriod('Arbetsgivardeklaration 202600')).toBeNull()
|
||||
})
|
||||
|
||||
it('rejects implausible years', () => {
|
||||
expect(parseAgiPeriod('Arbetsgivardeklaration 199912')).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────────
|
||||
// AGI period disambiguation in findMatchSuggestionsBulk
|
||||
// ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
function lineRow(opts: {
|
||||
entryId: string
|
||||
debit?: number
|
||||
credit?: number
|
||||
voucherNumber?: number | null
|
||||
entryDate?: string
|
||||
description?: string
|
||||
status?: 'draft' | 'posted' | 'reversed'
|
||||
}) {
|
||||
return {
|
||||
debit_amount: opts.debit ?? 0,
|
||||
credit_amount: opts.credit ?? 0,
|
||||
journal_entries: {
|
||||
id: opts.entryId,
|
||||
voucher_number: opts.voucherNumber ?? 12,
|
||||
voucher_series: 'A',
|
||||
entry_date: opts.entryDate ?? '2026-06-12',
|
||||
description: opts.description ?? 'AGI maj 2026',
|
||||
status: opts.status ?? 'posted',
|
||||
company_id: COMPANY,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
describe('findMatchSuggestionsBulk — AGI period disambiguation', () => {
|
||||
it('picks the AGI-linked entry even when two amount-matches exist', async () => {
|
||||
const { supabase, enqueue } = createQueuedMockSupabase()
|
||||
// Two journal entries credit 1630 with the same amount — without period
|
||||
// disambiguation, this would be ambiguous and return no suggestion.
|
||||
enqueue({
|
||||
data: [
|
||||
lineRow({ entryId: 'je-other-month', credit: 12345, entryDate: '2026-06-10' }),
|
||||
lineRow({ entryId: 'je-agi-may', credit: 12345, entryDate: '2026-06-12' }),
|
||||
],
|
||||
})
|
||||
enqueue({ data: [] }) // none already linked
|
||||
// AGI declarations lookup
|
||||
enqueue({
|
||||
data: [
|
||||
{
|
||||
period_year: 2026,
|
||||
period_month: 5,
|
||||
salary_run_id: 'sr-may-2026',
|
||||
},
|
||||
],
|
||||
})
|
||||
// salary_runs lookup
|
||||
enqueue({
|
||||
data: [
|
||||
{
|
||||
id: 'sr-may-2026',
|
||||
salary_entry_id: null,
|
||||
avgifter_entry_id: 'je-agi-may',
|
||||
vacation_entry_id: null,
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
const suggestions = await findMatchSuggestionsBulk(supabase as never, COMPANY, [
|
||||
{
|
||||
id: 'skv-1',
|
||||
transaktionsdatum: '2026-06-12',
|
||||
transaktionstext: 'Arbetsgivardeklaration 202605',
|
||||
belopp_skatteverket: -12345,
|
||||
journal_entry_id: null,
|
||||
},
|
||||
])
|
||||
|
||||
expect(suggestions.size).toBe(1)
|
||||
expect(suggestions.get('skv-1')).toMatchObject({
|
||||
journal_entry_id: 'je-agi-may',
|
||||
matched_via_agi_period: true,
|
||||
})
|
||||
})
|
||||
|
||||
it('falls back to amount-only matching when no AGI declaration exists for the period', async () => {
|
||||
const { supabase, enqueue } = createQueuedMockSupabase()
|
||||
enqueue({
|
||||
data: [lineRow({ entryId: 'je-unique', credit: 9999, entryDate: '2026-06-12' })],
|
||||
})
|
||||
enqueue({ data: [] })
|
||||
// No AGI declaration for 2026-05
|
||||
enqueue({ data: [] })
|
||||
// (no salary_runs lookup; agi_declarations was empty)
|
||||
|
||||
const suggestions = await findMatchSuggestionsBulk(supabase as never, COMPANY, [
|
||||
{
|
||||
id: 'skv-1',
|
||||
transaktionsdatum: '2026-06-12',
|
||||
transaktionstext: 'Arbetsgivardeklaration 202605',
|
||||
belopp_skatteverket: -9999,
|
||||
journal_entry_id: null,
|
||||
},
|
||||
])
|
||||
|
||||
expect(suggestions.size).toBe(1)
|
||||
expect(suggestions.get('skv-1')).toMatchObject({
|
||||
journal_entry_id: 'je-unique',
|
||||
matched_via_agi_period: false,
|
||||
})
|
||||
})
|
||||
|
||||
it('returns no suggestion when AGI-linked entries do not match the amount', async () => {
|
||||
const { supabase, enqueue } = createQueuedMockSupabase()
|
||||
// Two amount-matching entries, neither is the AGI-linked one.
|
||||
enqueue({
|
||||
data: [
|
||||
lineRow({ entryId: 'je-a', credit: 5000, entryDate: '2026-06-12' }),
|
||||
lineRow({ entryId: 'je-b', credit: 5000, entryDate: '2026-06-13' }),
|
||||
],
|
||||
})
|
||||
enqueue({ data: [] })
|
||||
enqueue({
|
||||
data: [
|
||||
{ period_year: 2026, period_month: 5, salary_run_id: 'sr-may-2026' },
|
||||
],
|
||||
})
|
||||
enqueue({
|
||||
data: [
|
||||
{
|
||||
id: 'sr-may-2026',
|
||||
salary_entry_id: 'je-different-amount',
|
||||
avgifter_entry_id: null,
|
||||
vacation_entry_id: null,
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
const suggestions = await findMatchSuggestionsBulk(supabase as never, COMPANY, [
|
||||
{
|
||||
id: 'skv-1',
|
||||
transaktionsdatum: '2026-06-12',
|
||||
transaktionstext: 'Arbetsgivardeklaration 202605',
|
||||
belopp_skatteverket: -5000,
|
||||
journal_entry_id: null,
|
||||
},
|
||||
])
|
||||
|
||||
expect(suggestions.size).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────────
|
||||
// AGI period boost in findMatchCandidates
|
||||
// ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
function txRow(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
id: 'skv-tx-1',
|
||||
company_id: COMPANY,
|
||||
transaktionsdatum: '2026-06-12',
|
||||
belopp_skatteverket: -12345,
|
||||
journal_entry_id: null,
|
||||
transaktionstext: 'Arbetsgivardeklaration 202605',
|
||||
status: 'booked',
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
describe('findMatchCandidates — AGI period boost', () => {
|
||||
it('moves the AGI-linked entry to position 0 even when a closer-by-date entry exists', async () => {
|
||||
const { supabase, enqueue } = createQueuedMockSupabase()
|
||||
enqueue({ data: txRow() })
|
||||
enqueue({
|
||||
data: [
|
||||
// closer to SKV date but unrelated
|
||||
lineRow({ entryId: 'je-close', credit: 12345, entryDate: '2026-06-12' }),
|
||||
// further from SKV date but AGI-linked
|
||||
lineRow({ entryId: 'je-agi', credit: 12345, entryDate: '2026-06-05' }),
|
||||
],
|
||||
})
|
||||
enqueue({ data: [] }) // none linked
|
||||
enqueue({
|
||||
data: [
|
||||
{ period_year: 2026, period_month: 5, salary_run_id: 'sr-may-2026' },
|
||||
],
|
||||
})
|
||||
enqueue({
|
||||
data: [
|
||||
{
|
||||
id: 'sr-may-2026',
|
||||
salary_entry_id: null,
|
||||
avgifter_entry_id: 'je-agi',
|
||||
vacation_entry_id: null,
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
const result = await findMatchCandidates(supabase as never, COMPANY, 'skv-tx-1')
|
||||
expect(result.candidates.map(c => c.journal_entry_id)).toEqual([
|
||||
'je-agi',
|
||||
'je-close',
|
||||
])
|
||||
expect(result.candidates[0].matched_via_agi_period).toBe(true)
|
||||
expect(result.candidates[1].matched_via_agi_period).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -27,6 +27,7 @@ import {
|
||||
} from './lib/agi-client'
|
||||
import { syncSkattekonto, SKATTEKONTO_BALANCE_SNAPSHOT_KEY, SKATTEKONTO_LAST_SYNCED_AT_KEY } from './lib/skattekonto-sync'
|
||||
import { bokforSkattekontoTransaction, SkattekontoBookingError } from './lib/skattekonto-booking'
|
||||
import { handleSkattekontoDriftDetected } from './lib/skattekonto-drift-email'
|
||||
import {
|
||||
findMatchCandidates,
|
||||
findMatchSuggestionsBulk,
|
||||
@@ -1893,6 +1894,13 @@ export const skatteverketExtension: Extension = {
|
||||
},
|
||||
},
|
||||
],
|
||||
|
||||
eventHandlers: [
|
||||
{
|
||||
eventType: 'skattekonto.drift_detected',
|
||||
handler: handleSkattekontoDriftDetected,
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────────────────
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { SupabaseClient } from '@supabase/supabase-js'
|
||||
import { createDraftEntry, findFiscalPeriod } from '@/lib/bookkeeping/engine'
|
||||
import { getPrimary as getPrimaryCashAccount } from '@/lib/cash-accounts/service'
|
||||
import type {
|
||||
CreateJournalEntryInput,
|
||||
CreateJournalEntryLineInput,
|
||||
@@ -9,8 +10,8 @@ import type {
|
||||
/**
|
||||
* Per-row "Bokför" helper.
|
||||
*
|
||||
* Takes a stored skattekonto_transactions row, guesses a counter-account
|
||||
* from the Swedish description text, and creates a DRAFT journal entry
|
||||
* Loads counter-account rules from `skattekonto_rules` (system seeds + per-company
|
||||
* overrides), picks the first match by priority, and creates a DRAFT journal entry
|
||||
* via the bookkeeping engine. The user reviews and commits the draft in
|
||||
* /bookkeeping/[id].
|
||||
*
|
||||
@@ -20,73 +21,37 @@ import type {
|
||||
* beloppSkatteverket < 0 (debit on tax account, e.g. F-tax charge)
|
||||
* → Credit 1630, Debit counter-account
|
||||
*
|
||||
* The keyword table mirrors lib/bookkeeping/booking-templates.ts entries
|
||||
* for skattekonto-related events, and is intentionally narrow — when no
|
||||
* keyword matches, throw SkattekontoBookingError and let the UI route the
|
||||
* user to a manual entry rather than fabricate a counter-account.
|
||||
* Anstånd has no system rule on purpose: it's a saldo-only deferral on the SKV
|
||||
* side and doesn't move the GL. NO_COUNTER_ACCOUNT lets the user handle the rare
|
||||
* case of anstånd granted across a closed period manually.
|
||||
*/
|
||||
|
||||
const SKATTEKONTO_ACCOUNT = '1630'
|
||||
|
||||
/**
|
||||
* Sentinel emitted by system rules for inbetalning / utbetalning: resolves to the
|
||||
* company's primary SEK cash account at runtime so the resolver doesn't assume 1930.
|
||||
* Falls back to '1930' until cash_accounts exists (Item 4 in the bank-architecture
|
||||
* priority list).
|
||||
*/
|
||||
const PRIMARY_SEK_SENTINEL = '__PRIMARY_SEK__'
|
||||
const PRIMARY_SEK_FALLBACK = '1930'
|
||||
|
||||
export type EntityType = 'enskild_firma' | 'aktiebolag'
|
||||
|
||||
interface CounterAccountRule {
|
||||
/** Lower-cased substrings; ANY matching wins. */
|
||||
match: string[]
|
||||
/** Counter-account number, possibly entity-type dependent. */
|
||||
account: string | { aktiebolag: string; enskild_firma: string }
|
||||
/** Optional human-readable label for the line description. */
|
||||
label?: string
|
||||
interface SkattekontoRuleRow {
|
||||
id: string
|
||||
priority: number
|
||||
pattern: string
|
||||
amount_min: number | null
|
||||
amount_max: number | null
|
||||
company_type: 'aktiebolag' | 'enskild_firma' | 'all'
|
||||
counter_account: string
|
||||
counter_account_ef: string | null
|
||||
label: string | null
|
||||
active: boolean
|
||||
}
|
||||
|
||||
const COUNTER_ACCOUNT_RULES: CounterAccountRule[] = [
|
||||
// Cash flows in/out
|
||||
{
|
||||
match: ['inbetalning bokförd', 'inbetalning', 'överföring från bank'],
|
||||
account: '1930',
|
||||
label: 'Inbetalning till skattekonto',
|
||||
},
|
||||
{
|
||||
match: ['utbetalning', 'återbetalning'],
|
||||
account: '1930',
|
||||
label: 'Utbetalning från skattekonto',
|
||||
},
|
||||
// Preliminary income tax — different liability accounts for AB vs EF
|
||||
{
|
||||
match: ['debiterad preliminärskatt', 'preliminärskatt', 'f-skatt', 'fskatt'],
|
||||
account: { aktiebolag: '2510', enskild_firma: '2012' },
|
||||
label: 'Preliminär skatt',
|
||||
},
|
||||
// Employer payroll taxes
|
||||
{
|
||||
match: ['arbetsgivaravgift', 'sociala avgifter', 'agi'],
|
||||
account: '2731',
|
||||
label: 'Arbetsgivaravgifter',
|
||||
},
|
||||
{
|
||||
match: ['avdragen skatt', 'personalskatt', 'a-skatt'],
|
||||
account: '2710',
|
||||
label: 'Avdragen skatt anställda',
|
||||
},
|
||||
// VAT — settlement account
|
||||
{
|
||||
match: ['mervärdesskatt', 'moms', 'momsdeklaration'],
|
||||
account: '2650',
|
||||
label: 'Redovisningskonto för moms',
|
||||
},
|
||||
// Interest — Skatteverket charges/credits interest on the account
|
||||
{
|
||||
match: ['kostnadsränta'],
|
||||
account: '8423',
|
||||
label: 'Kostnadsränta skattekonto',
|
||||
},
|
||||
{
|
||||
match: ['intäktsränta'],
|
||||
account: '8313',
|
||||
label: 'Intäktsränta skattekonto',
|
||||
},
|
||||
]
|
||||
|
||||
export class SkattekontoBookingError extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
@@ -102,30 +67,108 @@ export class SkattekontoBookingError extends Error {
|
||||
}
|
||||
}
|
||||
|
||||
interface CounterAccountMatch {
|
||||
export interface CounterAccountMatch {
|
||||
account: string
|
||||
label: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Find the counter-account for a Skatteverket transaktionstext.
|
||||
* Returns null if no rule matches. Public so tests can exercise it.
|
||||
* Resolve the primary SEK cash account for a company via `cash_accounts`.
|
||||
* Falls back to '1930' when no primary row exists yet (fresh company before the
|
||||
* initial PSD2 connection, or a manual-only company that hasn't set a primary).
|
||||
*/
|
||||
export function guessCounterAccount(
|
||||
async function resolvePrimarySekAccount(
|
||||
supabase: SupabaseClient,
|
||||
companyId: string,
|
||||
): Promise<string> {
|
||||
const primary = await getPrimaryCashAccount(supabase, companyId, 'SEK')
|
||||
return primary?.ledger_account ?? PRIMARY_SEK_FALLBACK
|
||||
}
|
||||
|
||||
/**
|
||||
* Find the counter-account for a Skatteverket transaktionstext by consulting
|
||||
* `skattekonto_rules` (system seeds + per-company overrides). Returns null when
|
||||
* no rule matches — the booking flow surfaces NO_COUNTER_ACCOUNT to the user.
|
||||
*
|
||||
* Rules are matched in priority order (lower numeric priority first), and for each
|
||||
* rule the `pattern` is split on commas to produce a list of lowercase substrings;
|
||||
* any substring contained in the normalized text wins.
|
||||
*/
|
||||
// Defence-in-depth check for any interpolation site. PostgREST .or() takes a
|
||||
// raw filter string, so we refuse company ids that aren't plain ASCII safe
|
||||
// characters (letters, digits, dash, underscore). The id should already be a
|
||||
// UUID at this call site — but rejecting anything else keeps the .or()
|
||||
// expression literal regardless of upstream bugs (ASVS V4.5).
|
||||
const SAFE_ID_PATTERN = /^[a-zA-Z0-9_-]+$/
|
||||
|
||||
// Explicit column projection — narrower than select('*'); ensures we don't
|
||||
// ship override metadata we don't need to the application layer (SOC 2
|
||||
// CC6.1, ISO 27001 A.8.5 least-privilege data access).
|
||||
const SKATTEKONTO_RULE_COLUMNS =
|
||||
'id, priority, pattern, amount_min, amount_max, company_type, counter_account, counter_account_ef, label, active'
|
||||
|
||||
export async function guessCounterAccount(
|
||||
supabase: SupabaseClient,
|
||||
companyId: string,
|
||||
transaktionstext: string,
|
||||
entityType: EntityType,
|
||||
): CounterAccountMatch | null {
|
||||
belopp?: number,
|
||||
): Promise<CounterAccountMatch | null> {
|
||||
if (!SAFE_ID_PATTERN.test(companyId)) {
|
||||
// The caller is supposed to pass a validated company id (from
|
||||
// requireCompanyId). Refuse rather than interpolate an unknown string
|
||||
// into the PostgREST filter — the .or() string parser is forgiving and
|
||||
// we don't want to depend on it for safety.
|
||||
return null
|
||||
}
|
||||
|
||||
const normalized = transaktionstext.toLowerCase()
|
||||
for (const rule of COUNTER_ACCOUNT_RULES) {
|
||||
if (rule.match.some(needle => normalized.includes(needle))) {
|
||||
const account =
|
||||
typeof rule.account === 'string' ? rule.account : rule.account[entityType]
|
||||
return {
|
||||
account,
|
||||
label: rule.label ?? transaktionstext,
|
||||
}
|
||||
const absBelopp = belopp === undefined ? null : Math.abs(belopp)
|
||||
|
||||
const { data: rules, error } = await supabase
|
||||
.from('skattekonto_rules')
|
||||
.select(SKATTEKONTO_RULE_COLUMNS)
|
||||
.eq('active', true)
|
||||
.or(`company_id.eq.${companyId},company_id.is.null`)
|
||||
.order('priority', { ascending: true })
|
||||
.order('id', { ascending: true })
|
||||
|
||||
if (error || !rules || rules.length === 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
for (const rule of rules as SkattekontoRuleRow[]) {
|
||||
if (rule.company_type !== 'all' && rule.company_type !== entityType) {
|
||||
continue
|
||||
}
|
||||
|
||||
if (absBelopp !== null) {
|
||||
if (rule.amount_min !== null && absBelopp < Number(rule.amount_min)) continue
|
||||
if (rule.amount_max !== null && absBelopp > Number(rule.amount_max)) continue
|
||||
}
|
||||
|
||||
const patterns = rule.pattern
|
||||
.split(',')
|
||||
.map(p => p.trim().toLowerCase())
|
||||
.filter(p => p.length > 0)
|
||||
|
||||
if (!patterns.some(p => normalized.includes(p))) continue
|
||||
|
||||
let account =
|
||||
entityType === 'enskild_firma' && rule.counter_account_ef
|
||||
? rule.counter_account_ef
|
||||
: rule.counter_account
|
||||
|
||||
if (account === PRIMARY_SEK_SENTINEL) {
|
||||
account = await resolvePrimarySekAccount(supabase, companyId)
|
||||
}
|
||||
|
||||
return {
|
||||
account,
|
||||
label: rule.label ?? transaktionstext,
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
@@ -135,7 +178,7 @@ export function guessCounterAccount(
|
||||
* Throws SkattekontoBookingError on:
|
||||
* - already-booked rows (journal_entry_id present)
|
||||
* - missing/locked fiscal period for the transaktionsdatum
|
||||
* - no keyword match → user must categorize manually
|
||||
* - no rule match → user must categorize manually
|
||||
*
|
||||
* Returns the created JournalEntry. Caller is responsible for writing
|
||||
* `journal_entry_id` back onto the skattekonto_transactions row.
|
||||
@@ -178,8 +221,14 @@ export async function bokforSkattekontoTransaction(
|
||||
const entityType: EntityType =
|
||||
(settings?.entity_type as EntityType) ?? 'aktiebolag'
|
||||
|
||||
// 3. Guess counter-account
|
||||
const guess = guessCounterAccount(tx.transaktionstext, entityType)
|
||||
// 3. Resolve counter-account via skattekonto_rules
|
||||
const guess = await guessCounterAccount(
|
||||
supabase,
|
||||
companyId,
|
||||
tx.transaktionstext,
|
||||
entityType,
|
||||
Number(tx.belopp_skatteverket),
|
||||
)
|
||||
if (!guess) {
|
||||
throw new SkattekontoBookingError(
|
||||
`Vi kunde inte gissa motkontot för "${tx.transaktionstext}". Skapa verifikatet manuellt.`,
|
||||
|
||||
@@ -0,0 +1,170 @@
|
||||
import { getEmailService } from '@/lib/email/service'
|
||||
import { createLogger } from '@/lib/logger'
|
||||
import { formatDate } from '@/lib/utils'
|
||||
import type { ExtensionContext } from '@/lib/extensions/types'
|
||||
import type { EventPayload } from '@/lib/events/types'
|
||||
|
||||
const log = createLogger('skattekonto-drift-email')
|
||||
|
||||
/**
|
||||
* Email handler for `skattekonto.drift_detected`. Notifies the company contact
|
||||
* that their cached Skatteverket saldo and the bookkeeping have diverged
|
||||
* beyond the configured tolerance — without putting the saldo or drift figures
|
||||
* in the email body. The actual numbers are surfaced behind authenticated UI
|
||||
* (the dashboard SkattekontoDriftTile) so a misdelivered mail doesn't leak
|
||||
* financial figures.
|
||||
*
|
||||
* Recipient resolution is restricted to active members of the company. A
|
||||
* stale company_settings.contact_email that no longer corresponds to a
|
||||
* member is never used. Falls back to the syncing user only if they're
|
||||
* still an active member.
|
||||
*
|
||||
* Degrades silently when no email service is registered (e.g. self-hosted
|
||||
* installations without Resend configured).
|
||||
*/
|
||||
export async function handleSkattekontoDriftDetected(
|
||||
payload: EventPayload<'skattekonto.drift_detected'>,
|
||||
ctx?: ExtensionContext,
|
||||
): Promise<void> {
|
||||
if (!ctx) {
|
||||
log.warn('drift event fired without ctx — cannot resolve recipient', {
|
||||
companyId: payload.companyId,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
const email = getEmailService()
|
||||
if (!email.isConfigured()) {
|
||||
log.info('email service not configured — skipping drift alert', {
|
||||
companyId: payload.companyId,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
const recipient = await resolveAuthorisedRecipient(ctx, payload.userId)
|
||||
if (!recipient) {
|
||||
log.warn('no authorised recipient resolved for drift alert', {
|
||||
companyId: payload.companyId,
|
||||
userId: payload.userId,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
const fetchedAt = formatDate(new Date(payload.fetchedAt).toISOString())
|
||||
const appUrl = (process.env.NEXT_PUBLIC_APP_URL || 'https://gnubok.se').replace(/\/$/, '')
|
||||
const dashboardLink = `${appUrl}/`
|
||||
|
||||
const subject = 'Skattekontot stämmer inte med bokföringen'
|
||||
|
||||
// Body intentionally carries no figures — only a notification that the
|
||||
// user should look at the dashboard tile. ISO 27001 A.8.11 / A.5.34: avoid
|
||||
// outbound financial data to addresses that may be stale.
|
||||
const lines = [
|
||||
`Vi har upptäckt en differens mellan ditt skattekonto och bokföringen per ${fetchedAt}.`,
|
||||
'',
|
||||
'Logga in på gnubok för att se beloppen och granska skattekonto-raderna:',
|
||||
dashboardLink,
|
||||
'',
|
||||
'Vanliga orsaker att differensen syns redan innan en åtgärd behövs:',
|
||||
'• Anstånd — saldot förskjuts hos Skatteverket men bokföringen påverkas inte.',
|
||||
'• Tidsskillnad — F-skatt debiteras den 12:e men förfaller senare, så Skatteverkets saldo kan ligga före bokföringen.',
|
||||
'• Obokförda skattekonto-rader som väntar på din kategorisering.',
|
||||
'',
|
||||
'Skapa inte en rättelseverifikation innan du har granskat raderna i gnubok.',
|
||||
]
|
||||
const text = lines.join('\n')
|
||||
|
||||
const html = `
|
||||
<p>Vi har upptäckt en differens mellan ditt skattekonto och bokföringen per ${escapeHtml(fetchedAt)}.</p>
|
||||
<p><a href="${escapeHtml(dashboardLink)}">Logga in på gnubok</a> för att se beloppen och granska skattekonto-raderna.</p>
|
||||
<p><strong>Vanliga orsaker att differensen syns redan innan en åtgärd behövs:</strong></p>
|
||||
<ul>
|
||||
<li>Anstånd — saldot förskjuts hos Skatteverket men bokföringen påverkas inte.</li>
|
||||
<li>Tidsskillnad — F-skatt debiteras den 12:e men förfaller senare, så Skatteverkets saldo kan ligga före bokföringen.</li>
|
||||
<li>Obokförda skattekonto-rader som väntar på din kategorisering.</li>
|
||||
</ul>
|
||||
<p>Skapa inte en rättelseverifikation innan du har granskat raderna i gnubok.</p>
|
||||
`.trim()
|
||||
|
||||
try {
|
||||
const result = await email.sendEmail({
|
||||
to: recipient,
|
||||
subject,
|
||||
text,
|
||||
html,
|
||||
})
|
||||
if (!result.success) {
|
||||
log.warn('drift email send failed', {
|
||||
companyId: payload.companyId,
|
||||
error: result.error,
|
||||
})
|
||||
}
|
||||
} catch (err) {
|
||||
log.error('drift email send threw', {
|
||||
companyId: payload.companyId,
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the recipient address for the drift alert and verify it belongs to
|
||||
* an active member of the company. A stale company_settings.contact_email
|
||||
* (set when a now-revoked admin still owned the company) must never receive
|
||||
* a drift notification because the bare existence of one is sensitive
|
||||
* financial signal.
|
||||
*/
|
||||
async function resolveAuthorisedRecipient(
|
||||
ctx: ExtensionContext,
|
||||
userId: string,
|
||||
): Promise<string | null> {
|
||||
// 1. Build the set of active member emails for this company. We accept
|
||||
// only addresses that appear here.
|
||||
const { data: members } = await ctx.supabase
|
||||
.from('company_members')
|
||||
.select('user_id, profiles!inner(email)')
|
||||
.eq('company_id', ctx.companyId)
|
||||
|
||||
type MemberRow = { user_id: string; profiles: { email?: string | null } | { email?: string | null }[] | null }
|
||||
const allowedEmails = new Set<string>()
|
||||
for (const m of (members ?? []) as MemberRow[]) {
|
||||
const profile = Array.isArray(m.profiles) ? m.profiles[0] : m.profiles
|
||||
if (profile?.email) allowedEmails.add(profile.email.toLowerCase())
|
||||
}
|
||||
|
||||
if (allowedEmails.size === 0) return null
|
||||
|
||||
// 2. Prefer the configured contact email IF it matches an active member.
|
||||
const { data: settings } = await ctx.supabase
|
||||
.from('company_settings')
|
||||
.select('contact_email')
|
||||
.eq('company_id', ctx.companyId)
|
||||
.maybeSingle()
|
||||
|
||||
const contactEmail = (settings as { contact_email?: string | null } | null)?.contact_email
|
||||
if (contactEmail && allowedEmails.has(contactEmail.toLowerCase())) {
|
||||
return contactEmail
|
||||
}
|
||||
|
||||
// 3. Fall back to the syncing user's email if they're still a member.
|
||||
const { data: profile } = await ctx.supabase
|
||||
.from('profiles')
|
||||
.select('email')
|
||||
.eq('id', userId)
|
||||
.maybeSingle()
|
||||
const userEmail = (profile as { email?: string | null } | null)?.email
|
||||
if (userEmail && allowedEmails.has(userEmail.toLowerCase())) {
|
||||
return userEmail
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
function escapeHtml(input: string): string {
|
||||
return input
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''')
|
||||
}
|
||||
@@ -0,0 +1,181 @@
|
||||
import type { SupabaseClient } from '@supabase/supabase-js'
|
||||
import type { ExtensionContext } from '@/lib/extensions/types'
|
||||
import type { SkattekontoBalanceSnapshot } from '../types'
|
||||
import { SKATTEKONTO_BALANCE_SNAPSHOT_KEY } from './skattekonto-sync'
|
||||
import { createLogger } from '@/lib/logger'
|
||||
|
||||
const log = createLogger('skattekonto-drift')
|
||||
|
||||
const SKATTEKONTO_BAS_ACCOUNT = '1630'
|
||||
const DRIFT_TOLERANCE_KEY = 'skattekonto_drift_tolerance'
|
||||
const DRIFT_LAST_ALERT_KEY = 'skattekonto_drift_last_alert_at'
|
||||
const DEFAULT_TOLERANCE_SEK = 1
|
||||
const ALERT_THROTTLE_MS = 24 * 60 * 60 * 1000
|
||||
|
||||
export interface SkattekontoDrift {
|
||||
saldoSkatteverket: number
|
||||
glSum1630: number
|
||||
/** SKV saldo - GL 1630 sum. Positive: SKV thinks we owe more than GL says. */
|
||||
drift: number
|
||||
fetchedAt: number
|
||||
/** Tolerance used when this drift was computed, in SEK. */
|
||||
tolerance: number
|
||||
/** Skattekonto rows without journal_entry_id, dated <= fetchedAt. */
|
||||
unbookedRows: Array<{
|
||||
id: string
|
||||
transaktionsdatum: string
|
||||
belopp_skatteverket: number
|
||||
transaktionstext: string
|
||||
}>
|
||||
}
|
||||
|
||||
export interface DriftAlertState {
|
||||
/** ms epoch of the last alert sent for this company. */
|
||||
lastAlertAt: number
|
||||
/** Sign of the drift at the last alert (-1, 0, +1). */
|
||||
lastSign: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute the difference between Skatteverket's cached saldo and the GL sum on
|
||||
* BAS 1630. Returns null when no snapshot exists yet (fresh company, never
|
||||
* synced).
|
||||
*
|
||||
* The comparison uses the snapshot's `fetchedAt` date as the GL cutoff: a
|
||||
* skattekonto sync at 04:00 today should only count GL entries posted with
|
||||
* entry_date <= today, otherwise a manual journal entry created in the same
|
||||
* day after the SKV pull would inflate the GL side and produce a false drift.
|
||||
*/
|
||||
export async function computeSkattekontoDrift(
|
||||
ctx: ExtensionContext,
|
||||
): Promise<SkattekontoDrift | null> {
|
||||
const snapshot = await ctx.settings.get<SkattekontoBalanceSnapshot>(
|
||||
SKATTEKONTO_BALANCE_SNAPSHOT_KEY,
|
||||
)
|
||||
if (!snapshot) return null
|
||||
|
||||
const fetchedDate = new Date(snapshot.fetchedAt).toISOString().slice(0, 10)
|
||||
const saldoSkatteverket = Number(snapshot.saldo.saldoSkatteverket) || 0
|
||||
|
||||
const glSum1630 = await sumGl1630(ctx.supabase, ctx.companyId, fetchedDate)
|
||||
// SKV side and GL side use opposite sign conventions in this codebase:
|
||||
// - SKV saldoSkatteverket > 0 means the taxpayer has a credit balance
|
||||
// with SKV (money sitting at Skatteverket).
|
||||
// - GL 1630 stores the SAME asset, so debit > credit means same direction
|
||||
// as SKV credit balance.
|
||||
// - sumGl1630 returns (sum(debit) - sum(credit)) which matches saldoSkatteverket.
|
||||
const drift = Math.round((saldoSkatteverket - glSum1630) * 100) / 100
|
||||
|
||||
const toleranceSetting = await ctx.settings.get<number>(DRIFT_TOLERANCE_KEY)
|
||||
const tolerance = typeof toleranceSetting === 'number' && toleranceSetting > 0
|
||||
? toleranceSetting
|
||||
: DEFAULT_TOLERANCE_SEK
|
||||
|
||||
const unbookedRows = await listUnbookedRows(ctx.supabase, ctx.companyId, fetchedDate)
|
||||
|
||||
return {
|
||||
saldoSkatteverket: Math.round(saldoSkatteverket * 100) / 100,
|
||||
glSum1630: Math.round(glSum1630 * 100) / 100,
|
||||
drift,
|
||||
fetchedAt: snapshot.fetchedAt,
|
||||
tolerance,
|
||||
unbookedRows,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Decide whether to emit `skattekonto.drift_detected` for this run, then update
|
||||
* the throttle state. Returns true when the event was emitted. Suppression
|
||||
* window is 24h unless the sign of the drift flips — a sign change means
|
||||
* something materially different is happening and the user should know.
|
||||
*/
|
||||
export async function maybeAlertDrift(
|
||||
ctx: ExtensionContext,
|
||||
drift: SkattekontoDrift,
|
||||
): Promise<boolean> {
|
||||
if (Math.abs(drift.drift) <= drift.tolerance) return false
|
||||
|
||||
const currentSign = Math.sign(drift.drift)
|
||||
const lastState = await ctx.settings.get<DriftAlertState>(DRIFT_LAST_ALERT_KEY)
|
||||
const now = Date.now()
|
||||
|
||||
const withinThrottle =
|
||||
!!lastState &&
|
||||
now - lastState.lastAlertAt < ALERT_THROTTLE_MS &&
|
||||
lastState.lastSign === currentSign
|
||||
|
||||
if (withinThrottle) {
|
||||
log.info('drift detected but within throttle window — skipping alert', {
|
||||
companyId: ctx.companyId,
|
||||
drift: drift.drift,
|
||||
lastAlertAt: lastState!.lastAlertAt,
|
||||
})
|
||||
return false
|
||||
}
|
||||
|
||||
await ctx.emit({
|
||||
type: 'skattekonto.drift_detected',
|
||||
payload: {
|
||||
drift: drift.drift,
|
||||
saldoSkatteverket: drift.saldoSkatteverket,
|
||||
glSum1630: drift.glSum1630,
|
||||
fetchedAt: drift.fetchedAt,
|
||||
unbookedCount: drift.unbookedRows.length,
|
||||
userId: ctx.userId,
|
||||
companyId: ctx.companyId,
|
||||
},
|
||||
})
|
||||
|
||||
await ctx.settings.set<DriftAlertState>(DRIFT_LAST_ALERT_KEY, {
|
||||
lastAlertAt: now,
|
||||
lastSign: currentSign,
|
||||
})
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
async function sumGl1630(
|
||||
supabase: SupabaseClient,
|
||||
companyId: string,
|
||||
cutoffDate: string,
|
||||
): Promise<number> {
|
||||
const { data, error } = await supabase
|
||||
.from('journal_entry_lines')
|
||||
.select('debit_amount, credit_amount, journal_entries!inner(company_id, entry_date, status)')
|
||||
.eq('account_number', SKATTEKONTO_BAS_ACCOUNT)
|
||||
.eq('journal_entries.company_id', companyId)
|
||||
.eq('journal_entries.status', 'posted')
|
||||
.lte('journal_entries.entry_date', cutoffDate)
|
||||
|
||||
if (error || !data) {
|
||||
log.warn('sumGl1630 failed', { companyId, cutoffDate, error: error?.message })
|
||||
return 0
|
||||
}
|
||||
|
||||
let sum = 0
|
||||
for (const row of data as Array<{ debit_amount: number | string; credit_amount: number | string }>) {
|
||||
sum += Number(row.debit_amount || 0) - Number(row.credit_amount || 0)
|
||||
}
|
||||
return Math.round(sum * 100) / 100
|
||||
}
|
||||
|
||||
async function listUnbookedRows(
|
||||
supabase: SupabaseClient,
|
||||
companyId: string,
|
||||
cutoffDate: string,
|
||||
): Promise<SkattekontoDrift['unbookedRows']> {
|
||||
const { data, error } = await supabase
|
||||
.from('skattekonto_transactions')
|
||||
.select('id, transaktionsdatum, belopp_skatteverket, transaktionstext')
|
||||
.eq('company_id', companyId)
|
||||
.is('journal_entry_id', null)
|
||||
.lte('transaktionsdatum', cutoffDate)
|
||||
.order('transaktionsdatum', { ascending: false })
|
||||
.limit(50)
|
||||
|
||||
if (error || !data) {
|
||||
log.warn('listUnbookedRows failed', { companyId, cutoffDate, error: error?.message })
|
||||
return []
|
||||
}
|
||||
return data as SkattekontoDrift['unbookedRows']
|
||||
}
|
||||
@@ -16,6 +16,12 @@ import type { StoredSkattekontoTransaction } from '../types'
|
||||
* The candidate query is intentionally strict (exact amount, exact side,
|
||||
* unused entry) — false positives would be silently destructive. False
|
||||
* negatives just fall back to "Bokför / Skapa manuellt".
|
||||
*
|
||||
* AGI period disambiguation: when transaktionstext carries an explicit period
|
||||
* token (e.g. "Arbetsgivardeklaration 202605"), the AGI declaration for that
|
||||
* period uniquely identifies the salary_run, and from there the salary entries.
|
||||
* That lets the matcher prefer the right entry even when two months happen to
|
||||
* have identical totals.
|
||||
*/
|
||||
|
||||
const SKATTEKONTO_ACCOUNT = '1630'
|
||||
@@ -45,18 +51,156 @@ export interface SkattekontoMatchCandidate {
|
||||
status: 'draft' | 'posted' | 'reversed'
|
||||
matched_amount: number
|
||||
matched_side: 'debit' | 'credit'
|
||||
/**
|
||||
* True when this candidate was picked because the SKV transaktionstext carried
|
||||
* an AGI period code matching the originating salary run. Used by the UI to
|
||||
* show a "period-matched" badge.
|
||||
*/
|
||||
matched_via_agi_period?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse an AGI period from a Skatteverket transaktionstext.
|
||||
*
|
||||
* Examples that match:
|
||||
* "Arbetsgivardeklaration 202605"
|
||||
* "arbetsgivardeklaration 2026-05"
|
||||
* "AGI 202605"
|
||||
*
|
||||
* Returns null when no period token is present or the value is out of range.
|
||||
*
|
||||
* The fallback (numeric YYYYMM after any AGI keyword) covers older SKV variants
|
||||
* that omit the leading word but still place the period adjacent to "AGI" or
|
||||
* "arbetsgivaravgift" elsewhere in the row.
|
||||
*/
|
||||
export function parseAgiPeriod(
|
||||
transaktionstext: string,
|
||||
): { year: number; month: number } | null {
|
||||
const text = transaktionstext.toLowerCase()
|
||||
|
||||
const primary = /arbetsgivardeklaration\s*(\d{4})[-]?(\d{2})/i.exec(transaktionstext)
|
||||
if (primary) {
|
||||
const year = Number(primary[1])
|
||||
const month = Number(primary[2])
|
||||
if (isValidPeriod(year, month)) return { year, month }
|
||||
}
|
||||
|
||||
const agiKeyword = /(arbetsgivardeklaration|arbetsgivaravgift|personalskatt|a-skatt|\bagi\b)/i
|
||||
if (!agiKeyword.test(text)) return null
|
||||
|
||||
const fallback = /(\d{4})[-]?(\d{2})\b/.exec(transaktionstext)
|
||||
if (fallback) {
|
||||
const year = Number(fallback[1])
|
||||
const month = Number(fallback[2])
|
||||
if (isValidPeriod(year, month)) return { year, month }
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
function isValidPeriod(year: number, month: number): boolean {
|
||||
return Number.isFinite(year) && Number.isFinite(month)
|
||||
&& year >= 2000 && year <= 2100
|
||||
&& month >= 1 && month <= 12
|
||||
}
|
||||
|
||||
interface AgiEntryLookup {
|
||||
/** Set of journal_entry_ids that belong to the AGI's salary run for that period. */
|
||||
entryIds: Set<string>
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve AGI-linked journal entries for a set of (year, month) periods.
|
||||
*
|
||||
* For each period: look up agi_declarations (UNIQUE per company per period), then
|
||||
* walk salary_runs.salary_entry_id / avgifter_entry_id / vacation_entry_id. Any
|
||||
* of those three entries is a legitimate match target for an SKV row carrying
|
||||
* the period code.
|
||||
*/
|
||||
async function loadAgiEntryIndex(
|
||||
supabase: SupabaseClient,
|
||||
companyId: string,
|
||||
periods: Array<{ year: number; month: number }>,
|
||||
): Promise<Map<string, AgiEntryLookup>> {
|
||||
const index = new Map<string, AgiEntryLookup>()
|
||||
if (periods.length === 0) return index
|
||||
|
||||
const uniqueKeys = new Set(periods.map(p => periodKey(p.year, p.month)))
|
||||
if (uniqueKeys.size === 0) return index
|
||||
|
||||
const years = Array.from(new Set(periods.map(p => p.year)))
|
||||
const months = Array.from(new Set(periods.map(p => p.month)))
|
||||
|
||||
const { data: agiRows } = await supabase
|
||||
.from('agi_declarations')
|
||||
.select('period_year, period_month, salary_run_id')
|
||||
.eq('company_id', companyId)
|
||||
.in('period_year', years)
|
||||
.in('period_month', months)
|
||||
|
||||
const salaryRunIdsByPeriod = new Map<string, string[]>()
|
||||
for (const row of (agiRows ?? []) as Array<{
|
||||
period_year: number
|
||||
period_month: number
|
||||
salary_run_id: string | null
|
||||
}>) {
|
||||
if (!row.salary_run_id) continue
|
||||
const key = periodKey(row.period_year, row.period_month)
|
||||
if (!uniqueKeys.has(key)) continue
|
||||
const existing = salaryRunIdsByPeriod.get(key) ?? []
|
||||
existing.push(row.salary_run_id)
|
||||
salaryRunIdsByPeriod.set(key, existing)
|
||||
}
|
||||
|
||||
const allSalaryRunIds = Array.from(
|
||||
new Set(Array.from(salaryRunIdsByPeriod.values()).flat()),
|
||||
)
|
||||
if (allSalaryRunIds.length === 0) return index
|
||||
|
||||
const { data: salaryRows } = await supabase
|
||||
.from('salary_runs')
|
||||
.select('id, salary_entry_id, avgifter_entry_id, vacation_entry_id')
|
||||
.eq('company_id', companyId)
|
||||
.in('id', allSalaryRunIds)
|
||||
|
||||
const entryIdsByRun = new Map<string, string[]>()
|
||||
for (const row of (salaryRows ?? []) as Array<{
|
||||
id: string
|
||||
salary_entry_id: string | null
|
||||
avgifter_entry_id: string | null
|
||||
vacation_entry_id: string | null
|
||||
}>) {
|
||||
const ids = [row.salary_entry_id, row.avgifter_entry_id, row.vacation_entry_id]
|
||||
.filter((id): id is string => !!id)
|
||||
entryIdsByRun.set(row.id, ids)
|
||||
}
|
||||
|
||||
for (const [key, runIds] of salaryRunIdsByPeriod) {
|
||||
const entryIds = new Set<string>()
|
||||
for (const runId of runIds) {
|
||||
for (const id of entryIdsByRun.get(runId) ?? []) entryIds.add(id)
|
||||
}
|
||||
if (entryIds.size > 0) index.set(key, { entryIds })
|
||||
}
|
||||
|
||||
return index
|
||||
}
|
||||
|
||||
function periodKey(year: number, month: number): string {
|
||||
return `${year}-${String(month).padStart(2, '0')}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Bulk-enrich a list of unmatched SKV rows with a `match_suggestion` field
|
||||
* pointing to a "high confidence" candidate verifikat. We only attach the
|
||||
* suggestion when there is EXACTLY ONE candidate — multiple matches means
|
||||
* we can't auto-suggest without risking the wrong link. The user can still
|
||||
* open the full Matcha-dialog manually in that case.
|
||||
* pointing to a "high confidence" candidate verifikat.
|
||||
*
|
||||
* Done in a single SQL pass to keep listing performance reasonable:
|
||||
* fetch all 1630-lines for entries in the widest possible date window
|
||||
* covering all rows, then match in-memory.
|
||||
* Matching has two layers:
|
||||
* 1. AGI period-code disambiguation. If the transaktionstext carries a period
|
||||
* token and the AGI declaration for that period maps to journal entries,
|
||||
* the matcher prefers candidates from that set even when other amount
|
||||
* matches exist.
|
||||
* 2. Strict amount+side match. We auto-suggest only when there is EXACTLY ONE
|
||||
* candidate to avoid silently linking the wrong entry.
|
||||
*/
|
||||
export async function findMatchSuggestionsBulk(
|
||||
supabase: SupabaseClient,
|
||||
@@ -64,6 +208,7 @@ export async function findMatchSuggestionsBulk(
|
||||
rows: Array<{
|
||||
id: string
|
||||
transaktionsdatum: string
|
||||
transaktionstext?: string | null
|
||||
belopp_skatteverket: number
|
||||
journal_entry_id: string | null
|
||||
}>,
|
||||
@@ -131,6 +276,18 @@ export async function findMatchSuggestionsBulk(
|
||||
.filter((id): id is string => !!id),
|
||||
)
|
||||
|
||||
// AGI period extraction across the batch.
|
||||
const periods: Array<{ year: number; month: number }> = []
|
||||
const periodByRowId = new Map<string, string>()
|
||||
for (const row of unmatched) {
|
||||
if (!row.transaktionstext) continue
|
||||
const period = parseAgiPeriod(row.transaktionstext)
|
||||
if (!period) continue
|
||||
periods.push(period)
|
||||
periodByRowId.set(row.id, periodKey(period.year, period.month))
|
||||
}
|
||||
const agiIndex = await loadAgiEntryIndex(supabase, companyId, periods)
|
||||
|
||||
const suggestions = new Map<string, SkattekontoMatchCandidate>()
|
||||
|
||||
for (const row of unmatched) {
|
||||
@@ -139,6 +296,11 @@ export async function findMatchSuggestionsBulk(
|
||||
const rowFrom = addDays(row.transaktionsdatum, -DATE_WINDOW_DAYS)
|
||||
const rowTo = addDays(row.transaktionsdatum, DATE_WINDOW_DAYS)
|
||||
|
||||
const periodEntryIds = (() => {
|
||||
const key = periodByRowId.get(row.id)
|
||||
return key ? agiIndex.get(key)?.entryIds ?? null : null
|
||||
})()
|
||||
|
||||
const matches: SkattekontoMatchCandidate[] = []
|
||||
const seen = new Set<string>()
|
||||
|
||||
@@ -166,12 +328,23 @@ export async function findMatchSuggestionsBulk(
|
||||
status: e.status,
|
||||
matched_amount: amount,
|
||||
matched_side: side,
|
||||
matched_via_agi_period: periodEntryIds?.has(e.id) ?? false,
|
||||
})
|
||||
|
||||
if (matches.length > 1) break
|
||||
if (matches.length > 1 && !periodEntryIds) break
|
||||
}
|
||||
|
||||
// Auto-suggest only when there's a single unambiguous match.
|
||||
// Period-code disambiguation: prefer the AGI-linked candidate even when
|
||||
// multiple amount-matches exist.
|
||||
if (periodEntryIds) {
|
||||
const periodMatches = matches.filter(m => m.matched_via_agi_period)
|
||||
if (periodMatches.length === 1) {
|
||||
suggestions.set(row.id, periodMatches[0])
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: auto-suggest only when there's a single unambiguous amount match.
|
||||
if (matches.length === 1) {
|
||||
suggestions.set(row.id, matches[0])
|
||||
}
|
||||
@@ -196,7 +369,8 @@ function expectedSide(beloppSkatteverket: number): 'debit' | 'credit' {
|
||||
* Find existing journal entries that look like the bank side of this
|
||||
* skattekonto row.
|
||||
*
|
||||
* Returns up to 25 candidates ordered by date proximity to the SKV row.
|
||||
* Returns up to 25 candidates ordered by AGI-period match, then date proximity
|
||||
* to the SKV row.
|
||||
*/
|
||||
export async function findMatchCandidates(
|
||||
supabase: SupabaseClient,
|
||||
@@ -300,6 +474,15 @@ export async function findMatchCandidates(
|
||||
.filter((id): id is string => !!id),
|
||||
)
|
||||
|
||||
// Resolve AGI-linked entries for this single row's period (if any).
|
||||
const period = tx.transaktionstext ? parseAgiPeriod(tx.transaktionstext) : null
|
||||
const agiIndex = period
|
||||
? await loadAgiEntryIndex(supabase, companyId, [period])
|
||||
: new Map<string, AgiEntryLookup>()
|
||||
const periodEntryIds = period
|
||||
? agiIndex.get(periodKey(period.year, period.month))?.entryIds ?? null
|
||||
: null
|
||||
|
||||
const seen = new Set<string>()
|
||||
const candidates: SkattekontoMatchCandidate[] = []
|
||||
for (const row of typedRows) {
|
||||
@@ -316,12 +499,16 @@ export async function findMatchCandidates(
|
||||
status: e.status,
|
||||
matched_amount: amount,
|
||||
matched_side: side,
|
||||
matched_via_agi_period: periodEntryIds?.has(e.id) ?? false,
|
||||
})
|
||||
}
|
||||
|
||||
// Order by date proximity to the SKV row, then by voucher number desc.
|
||||
// Order: AGI-period match first, then date proximity, then voucher number desc.
|
||||
const target = new Date(tx.transaktionsdatum + 'T00:00:00Z').getTime()
|
||||
candidates.sort((a, b) => {
|
||||
if (a.matched_via_agi_period !== b.matched_via_agi_period) {
|
||||
return a.matched_via_agi_period ? -1 : 1
|
||||
}
|
||||
const da = Math.abs(new Date(a.entry_date + 'T00:00:00Z').getTime() - target)
|
||||
const db = Math.abs(new Date(b.entry_date + 'T00:00:00Z').getTime() - target)
|
||||
if (da !== db) return da - db
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
-- Migration: skattekonto_rules — extensible counter-account rules for skattekontot
|
||||
--
|
||||
-- Why this exists: skattekonto-booking previously hardcoded an 8-entry array of
|
||||
-- (substring → counter-account) rules in TypeScript. Each new SKV transaktionstext
|
||||
-- pattern (omprövning, skattetillägg, förseningsavgift, ...) required a code change.
|
||||
-- This table lets users (and migrations) add rules without redeploys, mirroring the
|
||||
-- Fortnox "Regelverk" model.
|
||||
--
|
||||
-- Sign convention is handled by the caller (skattekonto-booking.ts): a positive
|
||||
-- belopp_skatteverket debits 1630 and credits counter_account; a negative belopp
|
||||
-- does the reverse. Rules only resolve the counter-account.
|
||||
--
|
||||
-- System seeds (company_id IS NULL): read-only to all companies; cannot be mutated
|
||||
-- via RLS. Per-company rules override system seeds via lower numeric priority.
|
||||
--
|
||||
-- Special sentinel: counter_account = '__PRIMARY_SEK__' resolves at runtime via
|
||||
-- the cash_accounts.is_primary lookup. Used so the inbetalning / utbetalning rules
|
||||
-- don't have to assume 1930 is the bank account for every company.
|
||||
--
|
||||
-- Account 8314 (Skattefria ränteintäkter) is used for intäktsränta instead of 8313
|
||||
-- (Ränteintäkter från bankgiro etc., taxable) because skattekontoräntan is skattefri
|
||||
-- per IL 8 kap 7 §.
|
||||
--
|
||||
-- Account 6992 (Övriga externa kostnader, ej avdragsgilla) catches skattetillägg
|
||||
-- and förseningsavgift — both are non-deductible penalties on SKV charges.
|
||||
--
|
||||
-- "Omprövning" deliberately has NO system rule: it's a re-assessment, not a
|
||||
-- penalty. The underlying tax (moms, F-skatt, AGI) is what changes — the existing
|
||||
-- moms/preliminärskatt/AGI rules cover those cases. Routing "omprövning" to 6992
|
||||
-- by keyword alone would mis-book a moms re-assessment as a non-deductible cost.
|
||||
--
|
||||
-- Anstånd is intentionally NOT given a rule. Anstånd is an SKV-side deferral
|
||||
-- (the saldo changes but no underlying tax is restated), so the GL doesn't move.
|
||||
-- The resolver returns null and the booking flow surfaces NO_COUNTER_ACCOUNT for
|
||||
-- the user to handle manually if a rare anstånd-across-closed-period case appears.
|
||||
|
||||
CREATE TABLE public.skattekonto_rules (
|
||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
-- NULL = system default seed, readable by every company.
|
||||
company_id UUID REFERENCES public.companies(id) ON DELETE CASCADE,
|
||||
-- Lower priority wins. Per-company rules typically use 1-49 to override system
|
||||
-- seeds (10-30).
|
||||
priority INTEGER NOT NULL,
|
||||
-- Comma-separated lowercase substrings; ANY match wins.
|
||||
pattern TEXT NOT NULL CHECK (length(pattern) > 0),
|
||||
amount_min NUMERIC,
|
||||
amount_max NUMERIC,
|
||||
company_type TEXT NOT NULL DEFAULT 'all'
|
||||
CHECK (company_type IN ('aktiebolag','enskild_firma','all')),
|
||||
-- BAS counter-account, or the literal sentinel '__PRIMARY_SEK__'.
|
||||
counter_account TEXT NOT NULL CHECK (length(counter_account) > 0),
|
||||
-- Override for enskild_firma when the same rule needs a different account for EF
|
||||
-- (e.g. preliminärskatt: AB → 2510, EF → 2012). NULL means use counter_account.
|
||||
counter_account_ef TEXT,
|
||||
label TEXT,
|
||||
active BOOLEAN NOT NULL DEFAULT true,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE INDEX idx_skattekonto_rules_lookup
|
||||
ON public.skattekonto_rules (company_id, priority)
|
||||
WHERE active = true;
|
||||
|
||||
ALTER TABLE public.skattekonto_rules ENABLE ROW LEVEL SECURITY;
|
||||
|
||||
-- System seeds are visible to all authenticated users; per-company rules only to
|
||||
-- members of that company.
|
||||
CREATE POLICY "skattekonto_rules_select" ON public.skattekonto_rules
|
||||
FOR SELECT USING (
|
||||
company_id IS NULL
|
||||
OR company_id IN (SELECT public.user_company_ids())
|
||||
);
|
||||
|
||||
-- Writes are scoped to companies the user belongs to. System seeds (company_id IS NULL)
|
||||
-- cannot be mutated via RLS — the WITH CHECK forces a non-NULL company_id.
|
||||
CREATE POLICY "skattekonto_rules_insert" ON public.skattekonto_rules
|
||||
FOR INSERT WITH CHECK (
|
||||
company_id IS NOT NULL
|
||||
AND company_id IN (SELECT public.user_company_ids())
|
||||
);
|
||||
|
||||
CREATE POLICY "skattekonto_rules_update" ON public.skattekonto_rules
|
||||
FOR UPDATE USING (
|
||||
company_id IS NOT NULL
|
||||
AND company_id IN (SELECT public.user_company_ids())
|
||||
)
|
||||
WITH CHECK (
|
||||
company_id IS NOT NULL
|
||||
AND company_id IN (SELECT public.user_company_ids())
|
||||
);
|
||||
|
||||
CREATE POLICY "skattekonto_rules_delete" ON public.skattekonto_rules
|
||||
FOR DELETE USING (
|
||||
company_id IS NOT NULL
|
||||
AND company_id IN (SELECT public.user_company_ids())
|
||||
);
|
||||
|
||||
CREATE TRIGGER skattekonto_rules_updated_at
|
||||
BEFORE UPDATE ON public.skattekonto_rules
|
||||
FOR EACH ROW EXECUTE FUNCTION public.update_updated_at_column();
|
||||
|
||||
-- ============================================================
|
||||
-- System seeds
|
||||
-- ============================================================
|
||||
--
|
||||
-- counter_account = '__PRIMARY_SEK__' on the in-/utbetalning rules so the resolver
|
||||
-- looks up cash_accounts.is_primary = true for SEK rather than assuming 1930.
|
||||
|
||||
INSERT INTO public.skattekonto_rules (
|
||||
company_id, priority, pattern, company_type, counter_account, counter_account_ef, label
|
||||
) VALUES
|
||||
(NULL, 10, 'inbetalning bokförd,inbetalning,överföring från bank', 'all',
|
||||
'__PRIMARY_SEK__', NULL, 'Inbetalning till skattekonto'),
|
||||
(NULL, 10, 'utbetalning,återbetalning', 'all',
|
||||
'__PRIMARY_SEK__', NULL, 'Utbetalning från skattekonto'),
|
||||
(NULL, 20, 'debiterad preliminärskatt,preliminärskatt,f-skatt,fskatt', 'all',
|
||||
'2510', '2012', 'Preliminär skatt'),
|
||||
(NULL, 20, 'arbetsgivaravgift,sociala avgifter,agi', 'all',
|
||||
'2731', NULL, 'Arbetsgivaravgifter'),
|
||||
(NULL, 20, 'avdragen skatt,personalskatt,a-skatt', 'all',
|
||||
'2710', NULL, 'Avdragen skatt anställda'),
|
||||
(NULL, 20, 'mervärdesskatt,moms,momsdeklaration', 'all',
|
||||
'2650', NULL, 'Redovisningskonto för moms'),
|
||||
(NULL, 25, 'skattetillägg,förseningsavgift', 'all',
|
||||
'6992', NULL, 'Ej avdragsgilla skatteavgifter'),
|
||||
(NULL, 30, 'kostnadsränta', 'all',
|
||||
'8423', NULL, 'Kostnadsränta skattekonto'),
|
||||
(NULL, 30, 'intäktsränta', 'all',
|
||||
'8314', NULL, 'Intäktsränta skattekonto');
|
||||
|
||||
NOTIFY pgrst, 'reload schema';
|
||||
@@ -0,0 +1,150 @@
|
||||
-- Migration: cash_accounts — first-class entity for routable cash accounts
|
||||
--
|
||||
-- Why this exists: bank_connections.accounts_data is a JSONB array storing
|
||||
-- PSD2 accounts plus their BAS ledger account mapping. That shape is fine for
|
||||
-- ingest but doesn't support:
|
||||
-- - per-account reconciliation (today's get_unlinked_1930_lines hardcodes 1930)
|
||||
-- - a stable foreign key from transactions to "the account that produced this row"
|
||||
-- - cash-on-hand / Stripe-clearing / BG-PG entries that aren't backed by PSD2
|
||||
-- - a primary-SEK designation so the skattekonto guesser stops hardcoding 1930
|
||||
--
|
||||
-- cash_accounts promotes the routing primitive to its own table. JSONB
|
||||
-- accounts_data remains the source for PSD2 sync metadata + UI display in this
|
||||
-- PR; cash_accounts is the canonical source for routing decisions. A follow-up
|
||||
-- migration after 30 days of stable operation will drop accounts_data.
|
||||
--
|
||||
-- Unique constraint is (company_id, ledger_account) per user decision. This
|
||||
-- enforces today's "one currency per BAS account" assumption (1930=SEK,
|
||||
-- 1932=EUR, 1933=USD, 1934=GBP). If a future Wise/Revolut multi-currency wallet
|
||||
-- customer appears, a follow-up migration would split the constraint to add
|
||||
-- currency as a third key column.
|
||||
--
|
||||
-- Partial unique index on (company_id) WHERE is_primary = true gives us
|
||||
-- at most one primary per company — used by skattekonto-booking's
|
||||
-- __PRIMARY_SEK__ sentinel resolver and by future multi-currency wallets.
|
||||
|
||||
CREATE TABLE public.cash_accounts (
|
||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
company_id UUID NOT NULL REFERENCES public.companies(id) ON DELETE CASCADE,
|
||||
-- bank_connection FK is nullable so manual / cash-on-hand / SIE-imported
|
||||
-- accounts can live without a PSD2 connection.
|
||||
bank_connection_id UUID REFERENCES public.bank_connections(id) ON DELETE SET NULL,
|
||||
external_uid TEXT, -- PSD2 StoredAccount.uid
|
||||
iban TEXT,
|
||||
bg_pg TEXT, -- Bankgiro / Plusgiro
|
||||
name TEXT,
|
||||
currency TEXT NOT NULL CHECK (length(currency) = 3),
|
||||
-- BAS account number (string, not int — '1930' not 1930).
|
||||
ledger_account TEXT NOT NULL CHECK (length(ledger_account) >= 4),
|
||||
balance NUMERIC,
|
||||
balance_updated_at TIMESTAMPTZ,
|
||||
enabled BOOLEAN NOT NULL DEFAULT true,
|
||||
is_primary BOOLEAN NOT NULL DEFAULT false,
|
||||
source TEXT NOT NULL DEFAULT 'enable_banking'
|
||||
CHECK (source IN ('enable_banking', 'manual', 'sie_import')),
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
UNIQUE (company_id, ledger_account),
|
||||
-- For PSD2-backed accounts, (bank_connection_id, external_uid) uniquely
|
||||
-- identifies the account inside that connection — guards against duplicate
|
||||
-- upserts during sync.
|
||||
UNIQUE (company_id, bank_connection_id, external_uid)
|
||||
);
|
||||
|
||||
CREATE INDEX idx_cash_accounts_company ON public.cash_accounts (company_id);
|
||||
CREATE INDEX idx_cash_accounts_iban
|
||||
ON public.cash_accounts (company_id, iban)
|
||||
WHERE iban IS NOT NULL;
|
||||
|
||||
-- At most one primary per company. Partial unique index — the standard pattern
|
||||
-- for "exactly one true row per group" (mirrors sie_imports active_partial_unique).
|
||||
CREATE UNIQUE INDEX idx_cash_accounts_one_primary_per_company
|
||||
ON public.cash_accounts (company_id)
|
||||
WHERE is_primary = true;
|
||||
|
||||
ALTER TABLE public.cash_accounts ENABLE ROW LEVEL SECURITY;
|
||||
|
||||
CREATE POLICY "cash_accounts_select" ON public.cash_accounts
|
||||
FOR SELECT USING (company_id IN (SELECT public.user_company_ids()));
|
||||
CREATE POLICY "cash_accounts_insert" ON public.cash_accounts
|
||||
FOR INSERT WITH CHECK (company_id IN (SELECT public.user_company_ids()));
|
||||
CREATE POLICY "cash_accounts_update" ON public.cash_accounts
|
||||
FOR UPDATE USING (company_id IN (SELECT public.user_company_ids()))
|
||||
WITH CHECK (company_id IN (SELECT public.user_company_ids()));
|
||||
CREATE POLICY "cash_accounts_delete" ON public.cash_accounts
|
||||
FOR DELETE USING (company_id IN (SELECT public.user_company_ids()));
|
||||
|
||||
CREATE TRIGGER cash_accounts_updated_at
|
||||
BEFORE UPDATE ON public.cash_accounts
|
||||
FOR EACH ROW EXECUTE FUNCTION public.update_updated_at_column();
|
||||
|
||||
-- ============================================================
|
||||
-- Backfill from bank_connections.accounts_data
|
||||
-- ============================================================
|
||||
--
|
||||
-- For each enabled StoredAccount in accounts_data, insert a cash_accounts row.
|
||||
-- Currency defaults to SEK; ledger_account defaults to 1930. ON CONFLICT DO
|
||||
-- NOTHING means re-running the migration is safe — and the (company_id,
|
||||
-- ledger_account) conflict naturally collapses duplicates that share a BAS code
|
||||
-- (older data sometimes had two SEK accounts both pointing to 1930; we keep
|
||||
-- the first one and the user can re-map later via the AccountPicker).
|
||||
|
||||
INSERT INTO public.cash_accounts (
|
||||
company_id, bank_connection_id, external_uid, iban, name,
|
||||
currency, ledger_account, balance, balance_updated_at, enabled, source
|
||||
)
|
||||
SELECT
|
||||
bc.company_id,
|
||||
bc.id,
|
||||
elem->>'uid',
|
||||
elem->>'iban',
|
||||
elem->>'name',
|
||||
COALESCE(UPPER(elem->>'currency'), 'SEK'),
|
||||
COALESCE(elem->>'ledger_account', '1930'),
|
||||
NULLIF(elem->>'balance', '')::NUMERIC,
|
||||
NULLIF(elem->>'balance_updated_at', '')::TIMESTAMPTZ,
|
||||
COALESCE((elem->>'enabled')::BOOLEAN, true),
|
||||
'enable_banking'
|
||||
FROM public.bank_connections bc,
|
||||
LATERAL jsonb_array_elements(COALESCE(bc.accounts_data, '[]'::jsonb)) AS elem
|
||||
WHERE bc.company_id IS NOT NULL
|
||||
AND elem->>'uid' IS NOT NULL
|
||||
ON CONFLICT (company_id, ledger_account) DO NOTHING;
|
||||
|
||||
-- First SEK row per company becomes primary. Falls back to the oldest cash
|
||||
-- account of any currency if no SEK row exists. The skattekonto resolver and
|
||||
-- transfer-pairing will use this row when the __PRIMARY_SEK__ sentinel is hit.
|
||||
WITH first_sek AS (
|
||||
SELECT DISTINCT ON (company_id) id
|
||||
FROM public.cash_accounts
|
||||
WHERE currency = 'SEK'
|
||||
ORDER BY company_id, created_at, id
|
||||
)
|
||||
UPDATE public.cash_accounts ca
|
||||
SET is_primary = true
|
||||
WHERE ca.id IN (SELECT id FROM first_sek);
|
||||
|
||||
-- Companies with no SEK account: pick any account as primary so the sentinel
|
||||
-- always resolves. Edge case (manual cash-on-hand only, etc.).
|
||||
WITH primary_missing AS (
|
||||
SELECT c.id AS company_id
|
||||
FROM public.companies c
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1 FROM public.cash_accounts ca
|
||||
WHERE ca.company_id = c.id AND ca.is_primary = true
|
||||
)
|
||||
AND EXISTS (
|
||||
SELECT 1 FROM public.cash_accounts ca
|
||||
WHERE ca.company_id = c.id
|
||||
)
|
||||
), first_any AS (
|
||||
SELECT DISTINCT ON (ca.company_id) ca.id
|
||||
FROM public.cash_accounts ca
|
||||
JOIN primary_missing pm ON pm.company_id = ca.company_id
|
||||
ORDER BY ca.company_id, ca.created_at, ca.id
|
||||
)
|
||||
UPDATE public.cash_accounts ca
|
||||
SET is_primary = true
|
||||
WHERE ca.id IN (SELECT id FROM first_any);
|
||||
|
||||
NOTIFY pgrst, 'reload schema';
|
||||
@@ -0,0 +1,72 @@
|
||||
-- Migration: parametrise the unmatched-GL-lines RPC so reconciliation works on
|
||||
-- any settlement account, not just 1930.
|
||||
--
|
||||
-- Why: until now `get_unlinked_1930_lines` hardcoded the bank-side BAS account.
|
||||
-- EUR/USD customers can't self-reconcile their 1932/1933 accounts and the
|
||||
-- skattekonto, payment-provider clearing, and BG/PG flows have no path. Now that
|
||||
-- cash_accounts exists, the RPC accepts an account number parameter and the UI
|
||||
-- can offer an account selector populated from that table.
|
||||
--
|
||||
-- The old function is dropped (no backwards-compatibility shim): the two
|
||||
-- in-repo callers (lib/reconciliation/bank-reconciliation.ts and
|
||||
-- app/api/reconciliation/bank/unmatched-entries/route.ts) are updated in the
|
||||
-- same PR. Anyone calling the RPC over the REST surface must switch to the new
|
||||
-- name + parameter at the same time.
|
||||
|
||||
DROP FUNCTION IF EXISTS public.get_unlinked_1930_lines(uuid, date, date);
|
||||
|
||||
CREATE FUNCTION public.get_unlinked_gl_lines(
|
||||
p_company_id UUID,
|
||||
p_account_number TEXT DEFAULT '1930',
|
||||
p_date_from DATE DEFAULT NULL,
|
||||
p_date_to DATE DEFAULT NULL
|
||||
)
|
||||
RETURNS TABLE (
|
||||
line_id UUID,
|
||||
journal_entry_id UUID,
|
||||
debit_amount NUMERIC,
|
||||
credit_amount NUMERIC,
|
||||
line_description TEXT,
|
||||
entry_date DATE,
|
||||
voucher_number INT,
|
||||
voucher_series TEXT,
|
||||
entry_description TEXT,
|
||||
source_type TEXT
|
||||
)
|
||||
LANGUAGE sql
|
||||
STABLE
|
||||
SECURITY DEFINER
|
||||
SET search_path = public
|
||||
AS $$
|
||||
SELECT
|
||||
jel.id AS line_id,
|
||||
je.id AS journal_entry_id,
|
||||
jel.debit_amount,
|
||||
jel.credit_amount,
|
||||
jel.line_description,
|
||||
je.entry_date,
|
||||
je.voucher_number,
|
||||
je.voucher_series,
|
||||
je.description AS entry_description,
|
||||
je.source_type
|
||||
FROM public.journal_entry_lines jel
|
||||
JOIN public.journal_entries je ON je.id = jel.journal_entry_id
|
||||
WHERE jel.account_number = p_account_number
|
||||
AND je.company_id = p_company_id
|
||||
AND je.status = 'posted'
|
||||
-- IB lines never have a counterpart in the bank feed — the bank statement
|
||||
-- starts at IB and accumulates from there. Keep them excluded from the
|
||||
-- unmatched set so reconciliation doesn't surface a phantom voucher.
|
||||
AND je.source_type IS DISTINCT FROM 'opening_balance'
|
||||
AND (p_date_from IS NULL OR je.entry_date >= p_date_from)
|
||||
AND (p_date_to IS NULL OR je.entry_date <= p_date_to)
|
||||
AND NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM public.transactions t
|
||||
WHERE t.journal_entry_id = je.id
|
||||
AND t.company_id = p_company_id
|
||||
)
|
||||
ORDER BY je.entry_date, je.voucher_number;
|
||||
$$;
|
||||
|
||||
NOTIFY pgrst, 'reload schema';
|
||||
@@ -0,0 +1,23 @@
|
||||
-- Migration: capture counterparty IBAN on transactions for transfer-pairing.
|
||||
--
|
||||
-- Why: PSD2 returns the creditor (outflow) or debtor (inflow) account IBAN on
|
||||
-- every booked transaction, but gnubok hasn't persisted it. Without an IBAN
|
||||
-- column, intra-account transfers ("move money from SEK 1930 to EUR 1932")
|
||||
-- can't be auto-detected and end up double-categorized.
|
||||
--
|
||||
-- The new column is nullable: SIE imports, manual entries, and pre-existing
|
||||
-- rows have no IBAN. counterparty_account stays as a fallback for Bankgiro /
|
||||
-- Plusgiro identifiers when IBAN is absent (Swedish domestic transfers).
|
||||
--
|
||||
-- The partial index supports the own-account detector's primary lookup:
|
||||
-- SELECT ... FROM transactions WHERE company_id = ? AND counterparty_iban = ?
|
||||
|
||||
ALTER TABLE public.transactions
|
||||
ADD COLUMN IF NOT EXISTS counterparty_iban TEXT,
|
||||
ADD COLUMN IF NOT EXISTS counterparty_account TEXT;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_transactions_counterparty_iban
|
||||
ON public.transactions (company_id, counterparty_iban)
|
||||
WHERE counterparty_iban IS NOT NULL;
|
||||
|
||||
NOTIFY pgrst, 'reload schema';
|
||||
@@ -0,0 +1,103 @@
|
||||
-- Migration: seed default 1930 SEK cash_account for every company
|
||||
--
|
||||
-- Why this exists: the cash_accounts backfill in 20260519110000_cash_accounts.sql
|
||||
-- only seeded rows from existing bank_connections. Companies that haven't
|
||||
-- connected a bank via PSD2 yet, or that do their books from SIE imports + manual
|
||||
-- entries only, end up with zero cash_accounts rows. Combined with the
|
||||
-- ownership check on /api/reconciliation/bank/{run,unmatched-entries,status},
|
||||
-- that means reconciliation became unreachable for those companies the moment we
|
||||
-- removed the '1930' bypass.
|
||||
--
|
||||
-- Two paths covered:
|
||||
-- 1. Backfill — insert (company_id, '1930', 'SEK', is_primary=true if no other
|
||||
-- primary exists) for every company missing a 1930 row. Idempotent via
|
||||
-- the (company_id, ledger_account) unique constraint.
|
||||
-- 2. Forward-fill — extend public.create_company_with_owner() to seed the
|
||||
-- same default row at company creation. Source 'manual' so it's clear
|
||||
-- this isn't a PSD2-backed account; the user can re-map or remove it via
|
||||
-- AccountPicker once a PSD2 connection is established.
|
||||
--
|
||||
-- Compliance: removes the last hidden bypass in the reconciliation routes that
|
||||
-- the PR review (ASVS V8.2.1, ISO 27001:2022 A.8.3, SOC 2 CC6.6) flagged as
|
||||
-- inconsistent authorization across cash accounts.
|
||||
|
||||
-- 1. Backfill: every company without a 1930 row gets one.
|
||||
INSERT INTO public.cash_accounts (
|
||||
company_id, ledger_account, currency, name, enabled, is_primary, source
|
||||
)
|
||||
SELECT
|
||||
c.id,
|
||||
'1930',
|
||||
'SEK',
|
||||
'Företagskonto (SEK)',
|
||||
true,
|
||||
-- Only flag as primary if no other primary exists for this company.
|
||||
NOT EXISTS (
|
||||
SELECT 1 FROM public.cash_accounts ca2
|
||||
WHERE ca2.company_id = c.id AND ca2.is_primary = true
|
||||
),
|
||||
'manual'
|
||||
FROM public.companies c
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1 FROM public.cash_accounts ca
|
||||
WHERE ca.company_id = c.id AND ca.ledger_account = '1930'
|
||||
)
|
||||
ON CONFLICT (company_id, ledger_account) DO NOTHING;
|
||||
|
||||
-- 2. Forward-fill: bake the seed into company creation so the reconciliation
|
||||
-- ownership check is always satisfiable.
|
||||
CREATE OR REPLACE FUNCTION public.create_company_with_owner(
|
||||
p_name text,
|
||||
p_entity_type text,
|
||||
p_set_active boolean DEFAULT true
|
||||
)
|
||||
RETURNS uuid
|
||||
LANGUAGE plpgsql
|
||||
SECURITY DEFINER
|
||||
SET search_path = public
|
||||
AS $$
|
||||
DECLARE
|
||||
v_user_id uuid;
|
||||
v_company_id uuid;
|
||||
BEGIN
|
||||
v_user_id := auth.uid();
|
||||
IF v_user_id IS NULL THEN
|
||||
RAISE EXCEPTION 'Not authenticated';
|
||||
END IF;
|
||||
|
||||
IF p_entity_type NOT IN ('enskild_firma', 'aktiebolag') THEN
|
||||
RAISE EXCEPTION 'Invalid entity_type: %', p_entity_type;
|
||||
END IF;
|
||||
|
||||
INSERT INTO public.companies (name, entity_type, created_by)
|
||||
VALUES (p_name, p_entity_type, v_user_id)
|
||||
RETURNING id INTO v_company_id;
|
||||
|
||||
INSERT INTO public.company_members (company_id, user_id, role)
|
||||
VALUES (v_company_id, v_user_id, 'owner');
|
||||
|
||||
-- Seed default 1930 SEK cash account so reconciliation routes work before
|
||||
-- any PSD2 connection is established. is_primary so the __PRIMARY_SEK__
|
||||
-- sentinel in skattekonto-booking resolves on day one.
|
||||
INSERT INTO public.cash_accounts (
|
||||
company_id, ledger_account, currency, name, enabled, is_primary, source
|
||||
)
|
||||
VALUES (
|
||||
v_company_id, '1930', 'SEK', 'Företagskonto (SEK)', true, true, 'manual'
|
||||
)
|
||||
ON CONFLICT (company_id, ledger_account) DO NOTHING;
|
||||
|
||||
IF p_set_active THEN
|
||||
INSERT INTO public.user_preferences (user_id, active_company_id)
|
||||
VALUES (v_user_id, v_company_id)
|
||||
ON CONFLICT (user_id)
|
||||
DO UPDATE SET active_company_id = EXCLUDED.active_company_id;
|
||||
END IF;
|
||||
|
||||
RETURN v_company_id;
|
||||
END;
|
||||
$$;
|
||||
|
||||
GRANT EXECUTE ON FUNCTION public.create_company_with_owner(text, text, boolean) TO authenticated;
|
||||
|
||||
NOTIFY pgrst, 'reload schema';
|
||||
@@ -0,0 +1,56 @@
|
||||
-- Migration: atomic set_cash_account_primary RPC
|
||||
--
|
||||
-- Why this exists: lib/cash-accounts/service.ts#setPrimary() previously did two
|
||||
-- separate UPDATEs (clear old primary, then set new primary). Between the two
|
||||
-- round-trips no row carries is_primary = true. If skattekonto-booking runs
|
||||
-- in that window and encounters a rule whose counter_account is '__PRIMARY_SEK__',
|
||||
-- getPrimary() returns null and the booking either falls back to '1930'
|
||||
-- (potentially wrong) or fails outright.
|
||||
--
|
||||
-- This RPC wraps both updates in a single BEGIN ... COMMIT so the
|
||||
-- intermediate "no primary" state is invisible to concurrent readers.
|
||||
-- SECURITY INVOKER so RLS still scopes which rows the caller may touch.
|
||||
--
|
||||
-- Compliance: addresses the P1 "Non-atomic setPrimary" review finding.
|
||||
|
||||
CREATE OR REPLACE FUNCTION public.set_cash_account_primary(
|
||||
p_company_id uuid,
|
||||
p_cash_account_id uuid
|
||||
)
|
||||
RETURNS void
|
||||
LANGUAGE plpgsql
|
||||
SECURITY INVOKER
|
||||
SET search_path = public
|
||||
AS $$
|
||||
BEGIN
|
||||
-- Guard: refuse to set primary on an account that doesn't exist or that
|
||||
-- belongs to a different company. RLS covers the second case but we want a
|
||||
-- clean error rather than a no-op silent UPDATE.
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM public.cash_accounts
|
||||
WHERE id = p_cash_account_id AND company_id = p_company_id
|
||||
) THEN
|
||||
RAISE EXCEPTION 'cash_account not found for company';
|
||||
END IF;
|
||||
|
||||
-- Both updates execute in the same statement-level transaction. The partial
|
||||
-- unique index idx_cash_accounts_one_primary_per_company is deferred to
|
||||
-- transaction commit only if explicitly deferred; UPDATE order matters and
|
||||
-- the index is non-deferrable today. Postgres still evaluates uniqueness
|
||||
-- at statement boundaries within the function body, so we clear first.
|
||||
UPDATE public.cash_accounts
|
||||
SET is_primary = false
|
||||
WHERE company_id = p_company_id
|
||||
AND is_primary = true
|
||||
AND id <> p_cash_account_id;
|
||||
|
||||
UPDATE public.cash_accounts
|
||||
SET is_primary = true
|
||||
WHERE company_id = p_company_id
|
||||
AND id = p_cash_account_id;
|
||||
END;
|
||||
$$;
|
||||
|
||||
GRANT EXECUTE ON FUNCTION public.set_cash_account_primary(uuid, uuid) TO authenticated;
|
||||
|
||||
NOTIFY pgrst, 'reload schema';
|
||||
@@ -0,0 +1,29 @@
|
||||
-- Migration: fix arbetsgivaravgifter mapping 2731 -> 2730
|
||||
--
|
||||
-- Why this exists: the initial seed in 20260519100000_skattekonto_rules.sql
|
||||
-- routed the AGI / arbetsgivaravgifter / sociala avgifter SKV pattern to 2731
|
||||
-- (Avräkning för arbetsgivaravgifter / accrued liability). The Swedish payroll
|
||||
-- skill (BAS-praxis) is unambiguous:
|
||||
-- * 7510 Lagstadgade sociala avgifter (kostnad) → debited monthly together
|
||||
-- with 2730 Lagstadgade sociala avgifter (skuld) on the credit side.
|
||||
-- * 2730 is the redovisningskonto (the running clearing account) that gets
|
||||
-- debited when SKV draws the amount from the skattekonto.
|
||||
-- * 2731 is the period-end accrual posting target (interimsskuld), used
|
||||
-- for the year-end semesterlöneskuld / accrued-but-not-paid leg.
|
||||
--
|
||||
-- Routing the AGI payment-clearing leg to 2731 leaves a permanent unexplained
|
||||
-- balance on 2730 after each AGI month closes because the accrual leg never
|
||||
-- gets cleared. Per swedish-payroll the correct counter-account for the
|
||||
-- skattekonto AGI debit is 2730.
|
||||
--
|
||||
-- Compliance: addresses the Swedish accounting compliance review finding
|
||||
-- "Arbetsgivaravgifter mapped to 2731, not 2730".
|
||||
|
||||
UPDATE public.skattekonto_rules
|
||||
SET counter_account = '2730',
|
||||
updated_at = now()
|
||||
WHERE company_id IS NULL
|
||||
AND counter_account = '2731'
|
||||
AND pattern = 'arbetsgivaravgift,sociala avgifter,agi';
|
||||
|
||||
NOTIFY pgrst, 'reload schema';
|
||||
@@ -193,6 +193,8 @@ export function makeTransaction(overrides: Partial<Transaction> = {}): Transacti
|
||||
document_id: null,
|
||||
import_source: null,
|
||||
reference: null,
|
||||
counterparty_iban: null,
|
||||
counterparty_account: null,
|
||||
notes: null,
|
||||
created_at: '2024-06-15T14:30:00Z',
|
||||
updated_at: '2024-06-15T14:30:00Z',
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
/**
|
||||
* pg-real test for get_unlinked_1930_lines (PR 3 of erp-mafia/gnubok#443).
|
||||
* pg-real test for get_unlinked_gl_lines.
|
||||
*
|
||||
* Verifies the RPC excludes opening_balance vouchers from the unmatched-1930
|
||||
* set, while preserving the existing behavior for posted bank-import vouchers,
|
||||
* date-range filtering, and company scoping.
|
||||
*
|
||||
* Migration: 20260514132534_unlinked_1930_lines_exclude_opening_balance.sql
|
||||
* date-range filtering, and company scoping. The RPC was renamed from
|
||||
* get_unlinked_1930_lines in 20260519120000_get_unlinked_gl_lines.sql; the
|
||||
* default p_account_number of '1930' keeps these assertions valid.
|
||||
*/
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { randomUUID } from 'node:crypto'
|
||||
@@ -55,7 +55,7 @@ async function insertPostedJournalEntry(params: {
|
||||
return id
|
||||
}
|
||||
|
||||
describe('get_unlinked_1930_lines RPC — opening_balance exclusion', () => {
|
||||
describe('get_unlinked_gl_lines RPC — opening_balance exclusion', () => {
|
||||
it('excludes opening_balance vouchers from the unmatched-1930 set', async () => {
|
||||
const userId = await insertAuthUser()
|
||||
const companyId = await insertCompany({ createdBy: userId })
|
||||
@@ -93,7 +93,7 @@ describe('get_unlinked_1930_lines RPC — opening_balance exclusion', () => {
|
||||
})
|
||||
|
||||
const { rows } = await getPool().query(
|
||||
`SELECT journal_entry_id, source_type FROM public.get_unlinked_1930_lines($1)`,
|
||||
`SELECT journal_entry_id, source_type FROM public.get_unlinked_gl_lines($1)`,
|
||||
[companyId],
|
||||
)
|
||||
|
||||
@@ -127,9 +127,12 @@ describe('get_unlinked_1930_lines RPC — opening_balance exclusion', () => {
|
||||
voucherNumber: 11,
|
||||
})
|
||||
|
||||
// Window covers only the second voucher.
|
||||
// Window covers only the second voucher. Use named notation so we don't have
|
||||
// to repeat the '1930' default just to reach the date params.
|
||||
const { rows } = await getPool().query(
|
||||
`SELECT entry_date FROM public.get_unlinked_1930_lines($1, $2, $3) ORDER BY entry_date`,
|
||||
`SELECT entry_date FROM public.get_unlinked_gl_lines(
|
||||
p_company_id => $1, p_date_from => $2, p_date_to => $3
|
||||
) ORDER BY entry_date`,
|
||||
[companyId, '2026-07-01', '2026-12-31'],
|
||||
)
|
||||
|
||||
@@ -155,11 +158,11 @@ describe('get_unlinked_1930_lines RPC — opening_balance exclusion', () => {
|
||||
})
|
||||
|
||||
const { rows: rowsA } = await getPool().query(
|
||||
`SELECT 1 FROM public.get_unlinked_1930_lines($1)`,
|
||||
`SELECT 1 FROM public.get_unlinked_gl_lines($1)`,
|
||||
[companyA],
|
||||
)
|
||||
const { rows: rowsB } = await getPool().query(
|
||||
`SELECT 1 FROM public.get_unlinked_1930_lines($1)`,
|
||||
`SELECT 1 FROM public.get_unlinked_gl_lines($1)`,
|
||||
[companyB],
|
||||
)
|
||||
expect(rowsA).toHaveLength(1)
|
||||
|
||||
@@ -317,6 +317,32 @@ export interface BankAccount {
|
||||
balance_updated_at?: string | null
|
||||
}
|
||||
|
||||
// Cash account — first-class entity for ledger-account routing decisions.
|
||||
// Backed by the cash_accounts table; bank_connections.accounts_data remains
|
||||
// the source for PSD2 sync metadata + UI display until a follow-up migration
|
||||
// drops it 30 days after this PR.
|
||||
export type CashAccountSource = 'enable_banking' | 'manual' | 'sie_import'
|
||||
|
||||
export interface CashAccount {
|
||||
id: string
|
||||
company_id: string
|
||||
bank_connection_id: string | null
|
||||
external_uid: string | null // PSD2 StoredAccount.uid
|
||||
iban: string | null
|
||||
bg_pg: string | null
|
||||
name: string | null
|
||||
currency: string // 3-char ISO; broader than Currency union to
|
||||
// tolerate future currencies without DB-driven enum drift
|
||||
ledger_account: string
|
||||
balance: number | null
|
||||
balance_updated_at: string | null
|
||||
enabled: boolean
|
||||
is_primary: boolean
|
||||
source: CashAccountSource
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
// Import source identifiers
|
||||
export type ImportSource =
|
||||
| 'enable_banking'
|
||||
@@ -384,6 +410,13 @@ export interface Transaction {
|
||||
import_source: string | null
|
||||
reference: string | null // OCR number, Bankgiro reference
|
||||
|
||||
// Counterparty identification from PSD2 (creditor for outflows, debtor for
|
||||
// inflows). The own-account transfer detector matches `counterparty_iban`
|
||||
// against cash_accounts.iban for the same company. `counterparty_account`
|
||||
// is the BG/PG/BBAN fallback for Swedish domestic transfers without IBAN.
|
||||
counterparty_iban: string | null
|
||||
counterparty_account: string | null
|
||||
|
||||
// Notes
|
||||
notes: string | null
|
||||
|
||||
@@ -2507,6 +2540,18 @@ export interface RawTransaction {
|
||||
reference?: string | null
|
||||
bank_connection_id?: string | null
|
||||
import_source?: string
|
||||
/**
|
||||
* Counterparty IBAN from PSD2 (creditor for outflows, debtor for inflows).
|
||||
* Used by the own-account transfer detector — when this matches another
|
||||
* cash_accounts row for the same company, both legs auto-book as a transfer.
|
||||
*/
|
||||
counterparty_iban?: string | null
|
||||
/**
|
||||
* Bankgiro / Plusgiro / BBAN fallback when no IBAN is available (typical
|
||||
* for Swedish domestic transfers). Kept distinct from IBAN so matching
|
||||
* doesn't accidentally collide BG numbers with IBAN strings.
|
||||
*/
|
||||
counterparty_account?: string | null
|
||||
}
|
||||
|
||||
/** Options for the transaction ingestion pipeline */
|
||||
|
||||
Reference in New Issue
Block a user