fix(skattekonto): remove the drift email, its event and the unused drift route (#2149)

The nightly skattekonto sync emailed "Skattekontot stämmer inte med
bokföringen" whenever Skatteverket's saldo differed from BAS 1630 by more
than 1 kr, every 24 hours while it lasted. On 2026-09-02 it fired on a
35 842 kr gap that the reconciliation explained to the last krona with 14
unbooked rows, while the Hem notice and the reconciliation page (both
gated on unexplained_difference) said nothing was wrong.

The check shipped in May 2026 (#525) before any in-app skattekonto view
existed; the dashboard tile its comments promise was never built and the
drift API route had no consumer. Since 2026-08-25 the reconciliation page
and the Hem notice are the surface, with one definition of "stämmer inte".

Removed: skattekonto-drift.ts, skattekonto-drift-email.ts, their tests,
the skattekonto.drift_detected event type, the handler registration, the
cron's drift hook, GET /api/extensions/skatteverket/skattekonto/drift, and
the ROPA activity for the mail. The route is dropped from the ungated
extension route allowlist to lock the ratchet. skattekonto_drift_tolerance
stays: the Hem notice reads it. Stale skattekonto_drift_last_alert_at rows
in extension_data are inert.

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Jakob Wennberg
2026-09-02 11:28:55 +02:00
committed by GitHub
co-authored by Jakob Wennberg Claude Fable 5.1
parent 5b64df9c80
commit 6e8d76a9cb
12 changed files with 9 additions and 1048 deletions
-39
View File
@@ -339,45 +339,6 @@ processing_activities:
- 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
- id: ai.inference
name: AI-inferens (kategorisering + dokumenttolkning) via Amazon Bedrock
purpose: >-
+1
View File
@@ -1482,3 +1482,4 @@ One line per decision: `[YYYY-MM-DD] <decision>: <why>`. Appended by agents and
[2026-09-01] ENABLE_BANKING_SANDBOX removed from the enable-banking manifest and the index.ts header (#2131): the variable was declared as optional but never read anywhere; sandbox vs production is decided by ENABLE_BANKING_API_URL (api.tilisy.com vs api.enablebanking.com, api-client.ts derives isSandbox from the host). A dead variable declared in the manifest is what the self-hosting docs would otherwise have copied. The manifest now lists the two optional variables the code actually reads (API_URL, PSU_TYPE); the _PRODUCTION aliases stay undeclared on purpose, they are a hosted Vercel convention, not an operator contract.
[2026-09-01] PR #2130 security-scan round: the register's djuplank is validated (https + skatteverket.se host) before it is returned or navigated to, since the settings page follows it; a contested org number now WITHDRAWS an already-recorded grant nightly (not only blocks new ones), outside the downgrade guards on purpose. NOT done: proof of org-number ownership (Bolagsverket firmatecknare / BankID) before any ombud grant; the org number is tenant-editable across the product (AGI, invoices, årsredovisning) and binding it to a verified identity is a product decision for Emil, tracked as a follow-up rather than declined.
[2026-09-02] Removed the skattekonto drift email (skattekonto.drift_detected event, handler, /api/extensions/skatteverket/skattekonto/drift route, cron hook) instead of fixing it: it alerted on raw saldo-vs-1630 gaps that unbooked rows explain by construction (2026-09-02: Arcim 35 842 kr, 100% explained, while the Hem notice and reconciliation page said nothing was wrong), repeated every 24 h, and was the only surface of a May-2026 feature whose promised dashboard tile was never built. Since 2026-08-25 the reconciliation page and the Hem notice (detectSkvUnexplained, gated on unexplained_difference) are the surface. Considered gating the mail on unexplained_difference + once per episode (built, then dropped): after that gate it only fires on integrity findings the engine itself calls 'never a user task'. skattekonto_drift_tolerance stays (Hem notice reads it); stale skattekonto_drift_last_alert_at rows in extension_data are inert.
@@ -1,34 +0,0 @@
import { NextResponse } from 'next/server'
import { ensureInitialized } from '@/lib/init'
import { withRouteContext } from '@/lib/api/with-route-context'
import { createExtensionContext } from '@/lib/extensions/context-factory'
import { computeSkattekontoDrift } from '@/extensions/general/skatteverket/lib/skattekonto-drift'
ensureInitialized()
/**
* 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 (Vercel logs; the
* observability sink only sees errors and no-ops until a provider is
* configured) 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 const GET = withRouteContext(
'skatteverket.skattekonto.drift',
async (_request, { supabase, user, companyId, log, requestId }) => {
const ctx = createExtensionContext(supabase, user.id, companyId, 'skatteverket', requestId)
const drift = await computeSkattekontoDrift(ctx)
log.info('skattekonto drift snapshot accessed', {
hasDrift: drift !== null,
})
return NextResponse.json({ data: drift })
},
)
@@ -7,8 +7,6 @@ const mocks = vi.hoisted(() => ({
getCompanyIdsWithCapability: vi.fn(),
createExtensionContext: vi.fn(),
syncSkattekonto: vi.fn(),
computeSkattekontoDrift: vi.fn(),
maybeAlertDrift: vi.fn(),
}))
vi.mock('@supabase/supabase-js', () => ({
@@ -34,11 +32,6 @@ vi.mock('@/extensions/general/skatteverket/lib/skattekonto-sync', () => ({
syncSkattekonto: (...args: unknown[]) => mocks.syncSkattekonto(...args),
}))
vi.mock('@/extensions/general/skatteverket/lib/skattekonto-drift', () => ({
computeSkattekontoDrift: (...args: unknown[]) => mocks.computeSkattekontoDrift(...args),
maybeAlertDrift: (...args: unknown[]) => mocks.maybeAlertDrift(...args),
}))
vi.mock('@/extensions/general/skatteverket/lib/api-client', () => {
class SkatteverketAuthError extends Error {
constructor(
@@ -120,7 +113,6 @@ describe('GET /api/extensions/skatteverket/skattekonto/sync/cron', () => {
(supabase: unknown, userId: string, companyId: string) => ({ supabase, userId, companyId }),
)
mocks.syncSkattekonto.mockResolvedValue({ booked: 0, upcoming: 0 })
mocks.computeSkattekontoDrift.mockResolvedValue(null)
errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
infoSpy = vi.spyOn(console, 'info').mockImplementation(() => {})
logSpy = vi.spyOn(console, 'log').mockImplementation(() => {})
@@ -7,7 +7,6 @@ import { orderByStalestSync } from '@/lib/skatteverket/sync-order'
import { CAPABILITY } from '@/lib/entitlements/keys'
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, type SkvAuth } from '@/extensions/general/skatteverket/lib/api-client'
import { SkatteverketSkattekontoError } from '@/extensions/general/skatteverket/lib/skattekonto-client'
import { markNeedsReconsent, RECONSENT_ERROR_CODES } from '@/extensions/general/skatteverket/lib/token-store'
@@ -239,19 +238,6 @@ export async function GET(request: Request) {
source === 'system' ? { mode: 'system' } : { mode: 'user', supabase, userId, companyId }
const syncResult = await syncSkattekonto(ctx, auth)
// 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,
@@ -1,310 +0,0 @@
/**
* Recipient resolution for the skattekonto drift alert.
*
* The alert must reach the address the company actually configured
* (company_settings.tax_contact_email, the "Kontaktperson för skatteärenden"
* field in Inställningar > Skatt), and every path that silently downgrades to
* the syncing user has to leave a log line: an alert delivered to the wrong
* inbox is indistinguishable from no alert at all.
*/
import { describe, it, expect, vi, beforeEach } from 'vitest'
import type { SupabaseClient } from '@supabase/supabase-js'
import type { EventPayload } from '@/lib/events/types'
const { warnRecorder } = vi.hoisted(() => ({ warnRecorder: vi.fn() }))
// log.warn is suppressed under NODE_ENV=test, so the failure-path tests
// observe it through this mock instead of a console spy.
vi.mock('@/lib/logger', () => ({
createLogger: () => ({
info: vi.fn(),
warn: warnRecorder,
error: vi.fn(),
child() {
return this
},
}),
}))
const mockIsConfigured = vi.fn()
const mockSendEmail = vi.fn()
vi.mock('@/lib/email/service', () => ({
getEmailService: () => ({ isConfigured: mockIsConfigured, sendEmail: mockSendEmail }),
}))
// The handler builds its own SERVICE-ROLE client: the only emitter is the
// nightly cron, where the registry-built ctx is an anonymous (or absent)
// client that RLS would turn into "no members, no recipient".
const { serviceClientHolder } = vi.hoisted(() => ({
serviceClientHolder: { current: null as unknown },
}))
vi.mock('@/lib/supabase/server', () => ({
createClient: vi.fn(),
createServiceClient: vi.fn(() => serviceClientHolder.current),
}))
import { handleSkattekontoDriftDetected } from '../lib/skattekonto-drift-email'
interface QueryRecord {
table: string
columns?: string
filters: Record<string, unknown>
}
type MemberRow = { user_id: string; email: string }
const OWNER: MemberRow = { user_id: 'user-1', email: 'owner@example.com' }
const ACCOUNTANT: MemberRow = { user_id: 'user-2', email: 'revisor@byra.se' }
/**
* Hand-rolled mock (instead of createQueuedMockSupabase) because the
* assertions need the selected COLUMN list per table: the bug this covers was
* a select of a column that does not exist on company_settings.
*
* Recipient resolution is the two-step lookup from
* lib/notifications/member-email: company_members yields user ids, profiles
* yields their emails via an .in() read.
*/
function makeSupabase(
opts: {
members?: MemberRow[] | null
membersError?: { message: string } | null
profilesError?: { message: string } | null
settings?: Record<string, unknown> | null
settingsError?: { message: string } | null
} = {},
) {
const members = opts.members === undefined ? [OWNER] : opts.members ?? []
const queries: QueryRecord[] = []
const from = (table: string) => {
const record: QueryRecord = { table, filters: {} }
queries.push(record)
const result = () => {
if (table === 'company_members') {
return {
data: opts.membersError ? null : members.map((m) => ({ user_id: m.user_id })),
error: opts.membersError ?? null,
}
}
if (table === 'company_settings') {
if (opts.settingsError) return { data: null, error: opts.settingsError }
const row = opts.settings
if (!row) return { data: null, error: null }
// Project to the selected columns, like PostgREST does: reading a
// column the handler did not ask for must not appear to work.
const projected: Record<string, unknown> = {}
for (const col of (record.columns ?? '').split(',').map((c) => c.trim())) {
if (col in row) projected[col] = row[col]
}
return { data: projected, error: null }
}
if (table === 'profiles') {
return {
data: opts.profilesError
? null
: members.map((m) => ({ id: m.user_id, email: m.email })),
error: opts.profilesError ?? null,
}
}
return { data: null, error: null }
}
const builder: Record<string, unknown> = {}
Object.assign(builder, {
select: (columns: string) => {
record.columns = columns
return builder
},
eq: (key: string, value: unknown) => {
record.filters[key] = value
return builder
},
in: (key: string, value: unknown) => {
record.filters[key] = value
return builder
},
order: () => builder,
range: () => builder,
maybeSingle: async () => {
const r = result()
const rows = r.data as Array<Record<string, unknown>> | null
return { data: Array.isArray(rows) ? rows[0] ?? null : rows, error: r.error }
},
then: (resolve: (v: unknown) => void) => resolve(result()),
})
return builder
}
return { supabase: { from } as unknown as SupabaseClient, queries }
}
/** Point the mocked createServiceClient at this test's supabase stub. */
function useServiceClient(supabase: SupabaseClient): void {
serviceClientHolder.current = supabase
}
const payload: EventPayload<'skattekonto.drift_detected'> = {
drift: -1250.5,
saldoSkatteverket: 10000,
glSum1630: 11250.5,
fetchedAt: Date.UTC(2026, 5, 30),
unbookedCount: 2,
userId: 'user-1',
companyId: 'company-1',
}
function sentTo(): string | undefined {
return mockSendEmail.mock.calls[0]?.[0]?.to as string | undefined
}
function warnedWith(fragment: string): boolean {
return warnRecorder.mock.calls.some(
(call) => typeof call[0] === 'string' && call[0].includes(fragment),
)
}
beforeEach(() => {
vi.clearAllMocks()
mockIsConfigured.mockReturnValue(true)
mockSendEmail.mockResolvedValue({ success: true })
})
describe('handleSkattekontoDriftDetected recipient resolution', () => {
it('sends to the configured tax contact when it belongs to an active member', async () => {
const { supabase } = makeSupabase({
members: [OWNER, ACCOUNTANT],
settings: { tax_contact_email: ACCOUNTANT.email },
})
useServiceClient(supabase)
await handleSkattekontoDriftDetected(payload)
expect(mockSendEmail).toHaveBeenCalledTimes(1)
expect(sentTo()).toBe(ACCOUNTANT.email)
})
it('reads tax_contact_email: the column the settings UI actually writes', async () => {
const { supabase, queries } = makeSupabase({
members: [OWNER, ACCOUNTANT],
settings: { tax_contact_email: ACCOUNTANT.email },
})
useServiceClient(supabase)
await handleSkattekontoDriftDetected(payload)
const settingsQuery = queries.find((q) => q.table === 'company_settings')
// Exact match, not `toContain`: 'tax_contact_email' contains the phantom
// 'contact_email' as a substring, so a loose assertion would pass on the bug.
expect(settingsQuery?.columns).toBe('tax_contact_email')
expect(settingsQuery?.filters).toMatchObject({ company_id: 'company-1' })
})
it('matches the configured contact against members case-insensitively', async () => {
const { supabase } = makeSupabase({
members: [OWNER, ACCOUNTANT],
settings: { tax_contact_email: 'Revisor@Byra.se' },
})
useServiceClient(supabase)
await handleSkattekontoDriftDetected(payload)
expect(sentTo()).toBe('Revisor@Byra.se')
})
it('falls back to the syncing user when no tax contact is configured', async () => {
const { supabase } = makeSupabase({ members: [OWNER], settings: null })
useServiceClient(supabase)
await handleSkattekontoDriftDetected(payload)
expect(sentTo()).toBe(OWNER.email)
})
it('refuses a tax contact that is not an active member, and says so', async () => {
const { supabase } = makeSupabase({
members: [OWNER],
settings: { tax_contact_email: 'ex-admin@example.com' },
})
useServiceClient(supabase)
await handleSkattekontoDriftDetected(payload)
expect(sentTo()).toBe(OWNER.email)
expect(warnedWith('not an active member')).toBe(true)
})
it('does not silently swallow a company_settings read error', async () => {
const { supabase } = makeSupabase({
members: [OWNER],
settings: null,
settingsError: { message: 'column company_settings.tax_contact_email does not exist' },
})
useServiceClient(supabase)
await handleSkattekontoDriftDetected(payload)
expect(warnedWith('could not read tax contact email')).toBe(true)
// Still delivers to the documented fallback rather than dropping the alert.
expect(sentTo()).toBe(OWNER.email)
})
it('does not silently swallow a member lookup error', async () => {
const { supabase } = makeSupabase({
members: null,
membersError: { message: 'permission denied for table company_members' },
})
useServiceClient(supabase)
await handleSkattekontoDriftDetected(payload)
expect(warnedWith('could not read company members')).toBe(true)
expect(mockSendEmail).not.toHaveBeenCalled()
})
it('does not silently swallow a member-email lookup error', async () => {
const { supabase } = makeSupabase({
members: [OWNER],
settings: null,
profilesError: { message: 'timeout' },
})
useServiceClient(supabase)
await handleSkattekontoDriftDetected(payload)
expect(warnedWith('could not read member emails')).toBe(true)
expect(mockSendEmail).not.toHaveBeenCalled()
})
it('sends nothing when the company has no members at all', async () => {
const { supabase } = makeSupabase({ members: [] })
useServiceClient(supabase)
await handleSkattekontoDriftDetected(payload)
expect(mockSendEmail).not.toHaveBeenCalled()
expect(warnedWith('no authorised recipient')).toBe(true)
})
it('skips quietly when no email service is configured', async () => {
mockIsConfigured.mockReturnValue(false)
const { supabase, queries } = makeSupabase({ members: [OWNER, ACCOUNTANT] })
useServiceClient(supabase)
await handleSkattekontoDriftDetected(payload)
expect(mockSendEmail).not.toHaveBeenCalled()
expect(queries).toHaveLength(0)
})
it('keeps the drift figures out of the email body', async () => {
const { supabase } = makeSupabase({
members: [OWNER, ACCOUNTANT],
settings: { tax_contact_email: ACCOUNTANT.email },
})
useServiceClient(supabase)
await handleSkattekontoDriftDetected(payload)
const body = `${mockSendEmail.mock.calls[0][0].text}${mockSendEmail.mock.calls[0][0].html}`
expect(body).not.toContain('1250')
expect(body).not.toContain('10000')
})
})
@@ -1,247 +0,0 @@
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]
}
/**
* Enqueue the two pages the 1630 sum reads through the two-step entry-lines
* fetch (lib/bookkeeping/entry-lines.ts): the parent entries first, then the
* bare lines keyed by journal_entry_id. Only the amounts are read downstream,
* so a single synthetic parent per line set is enough.
*/
function enqueueGlLines(
enqueue: (result: { data?: unknown; error?: unknown }) => void,
rows: Array<{ debit_amount: number; credit_amount: number }>,
) {
enqueue({ data: rows.length > 0 ? [{ id: 'entry-1' }] : [] })
if (rows.length === 0) return
enqueue({
data: rows.map((r, i) => ({ id: `line-${i}`, journal_entry_id: 'entry-1', ...r })),
})
}
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
enqueueGlLines(enqueue, [{ 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('returns null (skips the drift pass) when the GL 1630 read fails', async () => {
// A transient read failure must NOT be treated as glSum1630 = 0: with a
// cached SKV saldo of 5500 that would compute drift = 5500 and (throttled)
// email a false "Skattekontot stämmer inte med bokföringen".
const { supabase, enqueue } = createQueuedMockSupabase()
// The entry-lines fetch errors -> fetchAllRows throws -> sumGl1630 fails.
enqueue({ error: { message: 'connection reset by peer' } })
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).toBeNull()
})
it('honors a per-company override of the tolerance', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
enqueueGlLines(enqueue, [{ 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()
})
})
+8 -11
View File
@@ -66,7 +66,6 @@ import {
bokforSkattekontoTransactionsBatch,
SkattekontoBookingError,
} from './lib/skattekonto-booking'
import { handleSkattekontoDriftDetected } from './lib/skattekonto-drift-email'
import {
findMatchCandidates,
findMatchSuggestionsBulk,
@@ -2687,16 +2686,14 @@ export const skatteverketExtension: Extension = {
},
],
// skattekonto.connection.expired is still emitted (needs_reconsent flagging,
// UI banner, agent briefing) but has no email consumer: with SKV's 65-minute
// personal sessions a per-episode expiry mail is one mail per connect, which
// trains users to ignore it. See DECISIONS.md 2026-08-25.
eventHandlers: [
{
eventType: 'skattekonto.drift_detected',
handler: handleSkattekontoDriftDetected,
},
],
// No email consumers. skattekonto.connection.expired is still emitted
// (needs_reconsent flagging, UI banner, agent briefing) but a per-episode
// expiry mail is one mail per connect with SKV's 65-minute sessions, which
// trains users to ignore it (DECISIONS.md 2026-08-25). The skattekonto
// drift mail was removed 2026-09-02: the reconciliation page and the Hem
// notice (lib/notices/categories.ts detectSkvUnexplained) are the surface,
// and the mail alerted on raw saldo-vs-1630 gaps that unbooked rows explain.
eventHandlers: [],
// Registry-resolved commit services for the MCP submit tools. The core
// pending-operations dispatcher (lib/pending-operations/commit.ts) cannot
@@ -1,183 +0,0 @@
import type { SupabaseClient } from '@supabase/supabase-js'
import { getEmailService } from '@/lib/email/service'
import { createLogger } from '@/lib/logger'
import { resolveMemberEmails } from '@/lib/notifications/member-email'
import { createServiceClient } from '@/lib/supabase/server'
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.
*
* Service-role client, NOT the registry-built ctx: the only emitter is the
* nightly cron, whose request carries no user cookies, so the ctx the
* registry lazily builds there is an anonymous client (or undefined) that
* RLS turns into "no members, no recipient, no mail". Same rationale as the
* document-extraction handler and the retired connection-expired handler.
*
* Recipient resolution is restricted to active members of the company. A
* stale company_settings.tax_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> {
const email = getEmailService()
if (!email.isConfigured()) {
log.info('email service not configured: skipping drift alert', {
companyId: payload.companyId,
})
return
}
const supabase = createServiceClient()
const recipient = await resolveAuthorisedRecipient(supabase, payload.companyId, 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å Accounted 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å Accounted</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.tax_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.
*
* `tax_contact_email` is the "Kontaktperson för skatteärenden" field in
* Inställningar > Skatt (components/settings/TaxSettingsForm.tsx): the only
* place a company routes Skatteverket correspondence to someone other than
* whoever happened to trigger the sync.
*/
async function resolveAuthorisedRecipient(
supabase: SupabaseClient,
companyId: string,
userId: string,
): Promise<string | null> {
// 1. Build the set of active member emails for this company. We accept
// only addresses that appear here. A failed lookup resolves to an empty
// map, cancelling the alert: the helper logs it out loud.
const memberEmails = await resolveMemberEmails(supabase, companyId)
const allowedEmails = new Set<string>()
for (const email of memberEmails.values()) allowedEmails.add(email.toLowerCase())
if (allowedEmails.size === 0) return null
// 2. Prefer the configured tax contact email IF it matches an active member.
const { data: settings, error: settingsError } = await supabase
.from('company_settings')
.select('tax_contact_email')
.eq('company_id', companyId)
.maybeSingle()
if (settingsError) {
// Falling back to the syncing user is correct, but doing it without a
// trace is how a company silently stops getting alerts where it asked
// for them.
log.warn('could not read tax contact email: falling back to syncing user', {
companyId,
error: settingsError.message,
})
}
const contactEmail = (settings as { tax_contact_email?: string | null } | null)?.tax_contact_email
if (contactEmail) {
if (allowedEmails.has(contactEmail.toLowerCase())) {
return contactEmail
}
log.warn('configured tax contact is not an active member: falling back to syncing user', {
companyId,
})
}
// 3. Fall back to the syncing user's email: present in the map only while
// they are still a member.
const userEmail = memberEmails.get(userId)
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;')
}
@@ -1,189 +0,0 @@
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'
import { sumAccountBalance } from '@/lib/reconciliation/gl-balance'
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), and null when the GL read fails: a transient read failure must
* skip the drift pass for this run, not compute drift = the full SKV saldo
* and email a false "Skattekontot stämmer inte med bokföringen".
*
* 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)
if (glSum1630 === null) {
// The read failed (already warn-logged in sumGl1630 with the reason).
// Skip the drift pass for this company this run: a 0-substitute would
// make the "drift" equal the entire SKV saldo.
log.warn('skipping drift check for this run: GL 1630 read failed', {
companyId: ctx.companyId,
cutoffDate: fetchedDate,
})
return null
}
// 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
}
/**
* Sum debit - credit on BAS 1630 up to the cutoff. Returns null (NOT 0) when
* the read fails: 0 is a real balance claim ("nothing booked on 1630"), and
* substituting it for a failed read turns every transient DB blip into a
* full-saldo drift alert.
*
* Delegates to the core ledger-balance helper so the drift uses the SAME
* status predicate as the trial balance and the bank reconciliation
* (posted + reversed). Summing 'posted' alone excluded a stornoed original
* while counting its reversal, which misstated 1630 by the reversed amount
* for every company with a storno on the account (fixed 2026-08-23).
*/
async function sumGl1630(
supabase: SupabaseClient,
companyId: string,
cutoffDate: string,
): Promise<number | null> {
return sumAccountBalance(supabase, companyId, SKATTEKONTO_BAS_ACCOUNT, { cutoffDate })
}
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']
}
-12
View File
@@ -213,18 +213,6 @@ 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 } }
@@ -39,7 +39,6 @@ export const UNGATED_EXTENSION_ROUTES = new Set([
'app/api/extensions/enable-banking/callback/route.ts',
'app/api/extensions/enable-banking/sync/cron/route.ts',
'app/api/extensions/skatteverket/agi/kvittenser/cron/route.ts',
'app/api/extensions/skatteverket/skattekonto/drift/route.ts',
'app/api/extensions/skatteverket/skattekonto/sync/cron/route.ts',
'app/api/extensions/skatteverket/vat/kvittenser/cron/route.ts',
'app/api/extensions/stripe/callback/route.ts',