feat: implement skattekonto drift detection and alerting (#525)

* feat: implement skattekonto drift detection and alerting

- Add skattekonto drift computation logic to compare Skatteverket's saldo with GL 1630 sum.
- Implement alerting mechanism for significant drift changes, with throttling to prevent alert spamming.
- Introduce database functions to sum GL 1630 entries and list unbooked skattekonto rows.

feat: create own account transfer detection

- Develop logic to detect transfers between a company's own cash accounts based on counterparty IBAN.
- Implement tests to validate detection logic under various scenarios, including matching and non-matching IBANs.

feat: establish cash accounts as a first-class entity

- Create cash_accounts table to manage routable cash accounts, replacing ad-hoc JSONB structures.
- Implement functions for listing, upserting, and managing cash accounts, including primary account designation.

feat: enhance GL line reconciliation functionality

- Modify get_unlinked_1930_lines RPC to accept any account number for reconciliation, improving flexibility for different currencies.
- Update related functions to ensure compatibility with the new cash_accounts structure.

feat: capture counterparty IBAN in transactions

- Add counterparty_iban column to transactions table to facilitate intra-account transfer detection.
- Create index for efficient lookups based on counterparty IBAN.

* feat: Enhance cash account handling and reconciliation processes

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