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:
co-authored by
Jakob Wennberg
Claude Fable 5.1
parent
5b64df9c80
commit
6e8d76a9cb
@@ -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: >-
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''')
|
||||
}
|
||||
@@ -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']
|
||||
}
|
||||
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user