From 6e8d76a9cb7c5609feb1138294cd281190e761e6 Mon Sep 17 00:00:00 2001 From: Jakob Wennberg Date: Wed, 2 Sep 2026 11:28:55 +0200 Subject: [PATCH] fix(skattekonto): remove the drift email, its event and the unused drift route (#2149) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .compliance/ropa.yaml | 39 --- DECISIONS.md | 1 + .../skatteverket/skattekonto/drift/route.ts | 34 -- .../sync/cron/__tests__/route.test.ts | 8 - .../skattekonto/sync/cron/route.ts | 14 - .../__tests__/skattekonto-drift-email.test.ts | 310 ------------------ .../__tests__/skattekonto-drift.test.ts | 247 -------------- extensions/general/skatteverket/index.ts | 19 +- .../lib/skattekonto-drift-email.ts | 183 ----------- .../skatteverket/lib/skattekonto-drift.ts | 189 ----------- lib/events/types.ts | 12 - scripts/checks/extension-route-guards.mjs | 1 - 12 files changed, 9 insertions(+), 1048 deletions(-) delete mode 100644 app/api/extensions/skatteverket/skattekonto/drift/route.ts delete mode 100644 extensions/general/skatteverket/__tests__/skattekonto-drift-email.test.ts delete mode 100644 extensions/general/skatteverket/__tests__/skattekonto-drift.test.ts delete mode 100644 extensions/general/skatteverket/lib/skattekonto-drift-email.ts delete mode 100644 extensions/general/skatteverket/lib/skattekonto-drift.ts diff --git a/.compliance/ropa.yaml b/.compliance/ropa.yaml index 422b9713..e37cf692 100644 --- a/.compliance/ropa.yaml +++ b/.compliance/ropa.yaml @@ -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: >- diff --git a/DECISIONS.md b/DECISIONS.md index 074e0898..862a1ba7 100644 --- a/DECISIONS.md +++ b/DECISIONS.md @@ -1482,3 +1482,4 @@ One line per decision: `[YYYY-MM-DD] : `. 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. diff --git a/app/api/extensions/skatteverket/skattekonto/drift/route.ts b/app/api/extensions/skatteverket/skattekonto/drift/route.ts deleted file mode 100644 index 49ff570d..00000000 --- a/app/api/extensions/skatteverket/skattekonto/drift/route.ts +++ /dev/null @@ -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 }) - }, -) diff --git a/app/api/extensions/skatteverket/skattekonto/sync/cron/__tests__/route.test.ts b/app/api/extensions/skatteverket/skattekonto/sync/cron/__tests__/route.test.ts index be78898f..f9f00cf3 100644 --- a/app/api/extensions/skatteverket/skattekonto/sync/cron/__tests__/route.test.ts +++ b/app/api/extensions/skatteverket/skattekonto/sync/cron/__tests__/route.test.ts @@ -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(() => {}) diff --git a/app/api/extensions/skatteverket/skattekonto/sync/cron/route.ts b/app/api/extensions/skatteverket/skattekonto/sync/cron/route.ts index 1f166aa3..4212b52e 100644 --- a/app/api/extensions/skatteverket/skattekonto/sync/cron/route.ts +++ b/app/api/extensions/skatteverket/skattekonto/sync/cron/route.ts @@ -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, diff --git a/extensions/general/skatteverket/__tests__/skattekonto-drift-email.test.ts b/extensions/general/skatteverket/__tests__/skattekonto-drift-email.test.ts deleted file mode 100644 index 6d61c7f3..00000000 --- a/extensions/general/skatteverket/__tests__/skattekonto-drift-email.test.ts +++ /dev/null @@ -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 -} - -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 | 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 = {} - 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 = {} - 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> | 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') - }) -}) diff --git a/extensions/general/skatteverket/__tests__/skattekonto-drift.test.ts b/extensions/general/skatteverket/__tests__/skattekonto-drift.test.ts deleted file mode 100644 index dbfc62d7..00000000 --- a/extensions/general/skatteverket/__tests__/skattekonto-drift.test.ts +++ /dev/null @@ -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['supabase'] - settings: { get: ReturnType; set: ReturnType; clear?: ReturnType } - emit?: ReturnType -}) { - 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[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() - }) -}) diff --git a/extensions/general/skatteverket/index.ts b/extensions/general/skatteverket/index.ts index 7c17454a..f57707ad 100644 --- a/extensions/general/skatteverket/index.ts +++ b/extensions/general/skatteverket/index.ts @@ -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 diff --git a/extensions/general/skatteverket/lib/skattekonto-drift-email.ts b/extensions/general/skatteverket/lib/skattekonto-drift-email.ts deleted file mode 100644 index 2f4fadbe..00000000 --- a/extensions/general/skatteverket/lib/skattekonto-drift-email.ts +++ /dev/null @@ -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 { - 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 = ` -

Vi har upptäckt en differens mellan ditt skattekonto och bokföringen per ${escapeHtml(fetchedAt)}.

-

Logga in på Accounted för att se beloppen och granska skattekonto-raderna.

-

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.

-`.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 { - // 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() - 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, '&') - .replace(//g, '>') - .replace(/"/g, '"') - .replace(/'/g, ''') -} diff --git a/extensions/general/skatteverket/lib/skattekonto-drift.ts b/extensions/general/skatteverket/lib/skattekonto-drift.ts deleted file mode 100644 index 7630fdd9..00000000 --- a/extensions/general/skatteverket/lib/skattekonto-drift.ts +++ /dev/null @@ -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 { - const snapshot = await ctx.settings.get( - 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(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 { - if (Math.abs(drift.drift) <= drift.tolerance) return false - - const currentSign = Math.sign(drift.drift) - const lastState = await ctx.settings.get(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(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 { - return sumAccountBalance(supabase, companyId, SKATTEKONTO_BAS_ACCOUNT, { cutoffDate }) -} - -async function listUnbookedRows( - supabase: SupabaseClient, - companyId: string, - cutoffDate: string, -): Promise { - 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'] -} diff --git a/lib/events/types.ts b/lib/events/types.ts index 48b4f70d..33b8f5ff 100644 --- a/lib/events/types.ts +++ b/lib/events/types.ts @@ -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 } } diff --git a/scripts/checks/extension-route-guards.mjs b/scripts/checks/extension-route-guards.mjs index a5c840f5..4da8964b 100644 --- a/scripts/checks/extension-route-guards.mjs +++ b/scripts/checks/extension-route-guards.mjs @@ -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',