feat(skatteverket): repair notification recipients + make the agent the SKV notification surface (#1887)

* feat(skatteverket): repair notification recipients + make the agent the SKV notification surface

The company_members -> profiles!inner(email) PostgREST embed has no FK to
traverse (company_members.user_id references auth.users), so it 400'd and
silently killed all four notification emails since they shipped. Recipient
lookup is now a shared two-step helper (lib/notifications/member-email):
kvittens confirmations, skattekonto drift alerts (tax-contact routing
preserved via the plural variant) and backup alerts deliver again. The
connection-expired email is deleted instead of fixed: with SKV's 65-minute
personal sessions it was one mail per connect (see DECISIONS.md); the event
and needs_reconsent flagging stay.

For MCP-first users the agent is the notification surface, so:
- SKATTEVERKET_NOT_CONNECTED copy is now agent-directive: session expiry is
  normal (~1h by SKV design), only a person can reconnect with BankID, do
  not retry until they confirm. Inline strings (declaration-status, read
  routes, v1 pitfalls, accounted-api skill) aligned.
- gnubok_get_agent_briefing gains an optional skatteverket_connection block
  (status/source/connected_at + directive message on needs_reconsent),
  emitted only when a connection or verified system grant exists, so agents
  warn the user at session start instead of failing mid-task. Payload bench
  ceiling bumped 59.95K -> 60.15K for the outputSchema contract.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(skatteverket): drift email resolves recipients via service client; review fixes

The skeptic pass refuted the drift-email repair: skattekonto.drift_detected
is emitted only by the nightly cron, and the extension registry builds each
event handler a fresh ctx from the anonymous cookie client (or none at all
on cookieless requests), so RLS returned zero company_members rows and the
two-step lookup still resolved no recipient. The handler now builds its own
service-role client, the same documented pattern as the retired
connection-expired handler; drift tests exercise the handler without ctx,
matching the cron reality.

CodeRabbit findings: resolveMemberEmails pages both queries through
fetchAllRows with stable ordering (PostgREST caps unpaged reads at 1000
rows); the v1 vat-declarations pitfall and regenerated accounted-api docs
now name both auth paths (member BankID connection or verified ombud
grant); the briefing's system-before-user priority carries a cross-reference
to resolveReadAuth explaining why it is not reused. member-email.ts JSDoc
states the service-role-client requirement (profiles RLS is own-row-only).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Mattsson
2026-08-25 12:09:20 +02:00
committed by GitHub
co-authored by Claude Fable 5
parent f929b4b1d2
commit 1fa34aa7ca
21 changed files with 664 additions and 637 deletions
+3
View File
@@ -1194,6 +1194,9 @@ One line per decision: `[YYYY-MM-DD] <decision>: <why>`. Appended by agents and
[2026-08-24] Single-call chat console (general.help, AskConsole → /api/agent/ask) now carries the thread's earlier turns into every model call, via a new optional `history` on the provider-agnostic GenerateTextRequest (real message turns before the prompt in BOTH adapters: Anthropic-family messages array, OpenAI-compatible via AI SDK `messages`; an absent/empty history leaves the request byte-identical to the single-turn call, so hosted extraction and every other caller are untouched). The 08-20 RIP-3 cutover made each turn stateless (conversationId was only the tool actor id), so a follow-up in a resumed thread was answered blind (user report: "frågar vad jag refererar till"). History is loaded server-side from agent_messages (loadChatHistory: text only, hidden + tool rows dropped, alternation repaired, newest 16 rows / 10k chars) rather than sent by the client, so the client cannot forge earlier turns and old streaming threads replay cleanly. Rejected: inlining a transcript into the prompt (works everywhere but weaker turn semantics and blurs data vs instructions) and loading history in AskConsole (client-trusted history). Separately: the docked assistant panel now remembers its open thread per tab in sessionStorage (lib/agent-panel/session-restore) and reopens it after a full reload (the deploy prompt's "Ladda om" wiped it); sessionStorage, not user_preferences, because this is this-tab-this-session state that must not follow the user to other devices or tabs. And DeployReloadPrompt's full-width wrapper gets pointer-events-none: at z-[60] after the panel in DOM order it swallowed clicks on the panel's composer ("går ej att skriva").
[2026-08-25] /reports/bank-reconciliation retired behind a redirect to /reconciliation instead of kept as a "power" page: everything it did (matcher, manual N:1 matching, residual booking, IB tag, move-to-account) lives on the account-keyed page, and two reconciliation surfaces meant two truths. The catalog slug stays so old links, the report library and ?autorun=1 deep links keep working.
[2026-08-25] reconciliation_residual staged op tiered 'medium', not create_voucher's 'high': it books one typed verifikat (6570/8410/8310/3740 vs bank) bounded by RESIDUAL_MAX_AMOUNT and is undone by storno + unmatch, i.e. the same blast radius as categorize_transaction. Scope is transactions:write (same as the v1 route) because it writes the ledger.
[2026-08-25] Notification recipient lookup is a two-step query (lib/notifications/member-email), not the company_members -> profiles!inner(email) embed and not a new FK: company_members.user_id references auth.users, so PostgREST has no relationship to traverse and the embed 400'd, silently killing all four notification emails (kvittens, drift, backup, connection-expired) since they shipped. Adding an FK to profiles would be a migration on a core tenancy table for zero functional gain. Second lesson recorded: the drift path DID log the failure and nobody read it, so the guard is post-deploy delivery verification, not more logging.
[2026-08-25] The skattekonto connection-expired email is deleted, not fixed: SKV's per-flow refresh tokens live 65 minutes, so per-consent-episode dedup means one "your connection expired" mail per connect, arriving an hour after every successful BankID login; that trains users to ignore mail. Rejected alternative (kept for revisit): fix + throttle to one mail per user per 7 days, fired only when a scheduled sync actually failed. Residual accepted knowingly: web-only users now have NO proactive channel for a dead SKV connection (banner needs a visit, briefing needs an agent, drift email needs a working sync); the agent briefing's skatteverket_connection block and the rewritten SKATTEVERKET_NOT_CONNECTED copy are the compensating surfaces. The skattekonto.connection.expired event and needs_reconsent flagging stay.
[2026-08-25] SKATTEVERKET_NOT_CONNECTED stays one code for both never-connected and expired: splitting would ripple through every consumer (error-map, v1 routes, MCP dispatch, UI), and the declaration-status path already differentiates in its message. The copy is agent-directive on purpose (only a person can run BankID; do not retry until the user confirms) because the old English copy "Reconnect with BankID before retrying" invited agents to retry something only a human can fix.
[2026-08-24] Manual reconciliation adapter (Reko bilagor, PR 1) computes the ledger side per fiscal period via generateTrialBalance (IB + movement through the balansdag), never as an all-history sumAccountBalance: year-end posts an opening_balance verifikat that re-books every balance account in the new year, so an all-history sum counts a closed year twice. Reskontra/semesterskuld specifications are "per idag" (open items now), labeled so in the bridge; a per-date reskontra is a follow-up. A typed external_balance is accepted only on manual accounts without a system specification (EXTERNAL_BALANCE_NOT_ALLOWED elsewhere): letting a stated number override the bank, Skatteverket or the reskontra would hide the very difference the sign-off exists to record.
[2026-08-24] Reconciliation underlag (Reko bilagor, PR 2) is its own table (account_reconciliation_attachments) scoped by (company, account_key, through_date), not extra columns on document_attachments: that table's link is a verifikat and its WORM version chain is about digitized receipts, while a bilaga belongs to a balansdag and may be attached before the sign-off exists. Files stay in the `documents` bucket under `documents/<company>/reconciliation/...` so the bucket's company-scoped RLS applies unchanged; removal is a stamp (never a delete, BFL 7 kap.) enforced by trigger; the full archive copies the files into `bilagor/` with a hash manifest. No v1 API endpoints in this PR on purpose: the concurrent reconciliation-residual work edits the v1 route loader, spec snapshot and scopes, and files cannot be uploaded by an agent anyway.
[2026-08-24] Bokslut checklist (Reko bilagor, PR 3) keeps the item catalogue in code and only the per-period state in bokslut_checklist_items: steps the system can judge (drafts, voucher gaps, trial balance, sign-offs through balansdagen, reskontra tie-outs) are computed live every time and a stored row only overrides them, so the checklist never claims a state the ledger contradicts; manual steps (inventering, osäkra fordringar, dispositioner) are what the konsult ticks. Mutable on purpose (a late verifikat reopens a step), no DELETE policy. The missing-fiscal-year check is a pure helper reused by the readiness warnings and the SIE import result; the non-adjacent previous_period_id fix is #1849 and is not duplicated here.
@@ -62,7 +62,7 @@ registerEndpoint({
doNotUseFor:
'Computing the declaration from the books (use the VAT report), or filing: submission is a separate BankID-signed flow.',
pitfalls: [
'This is a live Skatteverket read: it fails with SKATTEVERKET_NOT_CONNECTED (401) until someone in the company has connected with BankID under Installningar, and the response reflects SKV\'s state, not the books.',
'This is a live Skatteverket read: it fails with SKATTEVERKET_NOT_CONNECTED (401) when the company has neither a member\'s BankID connection (made under Installningar) nor a verified ombud grant, and the response reflects SKV\'s state, not the books. Personal BankID sessions expire after ~1 hour by design, so an expired connection is normal: ask the user to reconnect; only a person can, so do not retry until they confirm.',
'submitted=null and decided=null with HTTP 200 means "nothing on file for the period": it is not an error.',
'A submitted declaration can lack a beslut for days: poll decided separately rather than assuming both appear together.',
'redovisningsperiod is SKV\'s YYYYMM format (the period\'s LAST month): quarterly period 1 is 03, not 01.',
@@ -80,8 +80,8 @@ describe('shouldSendBackupAlert', () => {
})
/**
* Supabase stub: company_members lookup resolves a member with an email,
* company_settings resolves a company name.
* Supabase stub: the two-step recipient lookup (company_members membership
* check, then profiles email), and company_settings resolves a company name.
*/
function makeSupabase(options: { member?: unknown; companyName?: string | null } = {}) {
const from = vi.fn().mockImplementation((table: string) => {
@@ -91,14 +91,16 @@ function makeSupabase(options: { member?: unknown; companyName?: string | null }
maybeSingle: vi.fn().mockImplementation(() => {
if (table === 'company_members') {
return Promise.resolve({
data:
options.member !== undefined
? options.member
: { user_id: 'u-1', profiles: { email: 'emil@example.com' } },
data: options.member !== undefined ? options.member : { user_id: 'u-1' },
error: null,
})
}
if (table === 'profiles') {
return Promise.resolve({ data: { email: 'emil@example.com' }, error: null })
}
return Promise.resolve({
data: { company_name: options.companyName ?? 'Testbolag AB' },
error: null,
})
}),
}
@@ -14,6 +14,7 @@
import type { SupabaseClient } from '@supabase/supabase-js'
import { getEmailService } from '@/lib/email/service'
import { createLogger } from '@/lib/logger'
import { resolveMemberEmail } from '@/lib/notifications/member-email'
const log = createLogger('cloud-backup-alert')
@@ -114,29 +115,6 @@ export async function sendBackupFailureAlert(
}
}
/**
* The recipient must still be an active member of the company: a schedule
* owner who has since been removed must not receive alerts for it.
*/
async function resolveMemberEmail(
supabase: SupabaseClient,
companyId: string,
userId: string
): Promise<string | null> {
const { data: member } = await supabase
.from('company_members')
.select('user_id, profiles!inner(email)')
.eq('company_id', companyId)
.eq('user_id', userId)
.maybeSingle()
if (!member) return null
type ProfileRef = { email?: string | null } | { email?: string | null }[] | null
const profiles = (member as { profiles: ProfileRef }).profiles
const profile = Array.isArray(profiles) ? profiles[0] : profiles
return profile?.email ?? null
}
async function fetchCompanyName(
supabase: SupabaseClient,
companyId: string
@@ -2,7 +2,7 @@
* Tests for gnubok_get_agent_briefing: session-bootstrap context for the
* specialized accountant agent over MCP.
*/
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import { tools } from '../server'
import { RECOMMENDED_WORKFLOW_LOADOUTS, assertRecommendedLoadoutsValid } from '../recommended-tools'
import { workflowSkills } from '../skills'
@@ -57,6 +57,8 @@ function mockSupabase(opts: {
dimensionValueRows?: Array<{ dimension_id: string; code: string; name: string }>
dimensionRuleRows?: Array<{ account_number: string; rule_type: string; dimension_id: string }>
dimensionsEnabled?: boolean
// skatteverket_tokens rows for the connection-health block. Default: none.
skvTokenRows?: Array<{ user_id: string; status: string | null; created_at: string | null }>
errors?: { profile?: string; memory?: string; atoms?: string }
}) {
const profile = opts.profile === undefined ? null : opts.profile
@@ -171,6 +173,9 @@ function mockSupabase(opts: {
// PR10: the briefing surfaces active rules; default none.
return chainResolving(opts.dimensionRuleRows ?? [])
}
if (table === 'skatteverket_tokens') {
return chainResolving(opts.skvTokenRows ?? [])
}
throw new Error(`Unexpected table in test mock: ${table}`)
}),
}
@@ -455,6 +460,107 @@ describe('gnubok_get_agent_briefing tool', () => {
})
})
describe('skatteverket_connection health block', () => {
const tool = () => tools.find((t) => t.name === 'gnubok_get_agent_briefing')!
const originalEnabled = process.env.SKATTEVERKET_ENABLED
beforeEach(() => {
process.env.SKATTEVERKET_ENABLED = 'true'
})
afterEach(() => {
if (originalEnabled === undefined) delete process.env.SKATTEVERKET_ENABLED
else process.env.SKATTEVERKET_ENABLED = originalEnabled
})
it('omits the block entirely for a company that never connected', async () => {
const supabase = mockSupabase({ profile: null, skvTokenRows: [] })
const result = (await tool().execute(
{},
'company-1',
'user-1',
supabase as never,
{ type: 'api_key' }
)) as Record<string, unknown>
expect('skatteverket_connection' in result).toBe(false)
})
it('omits the block when the skatteverket extension is disabled, even with a token row', async () => {
process.env.SKATTEVERKET_ENABLED = 'false'
const supabase = mockSupabase({
profile: null,
skvTokenRows: [{ user_id: 'user-1', status: 'active', created_at: '2026-08-25T09:12:00Z' }],
})
const result = (await tool().execute(
{},
'company-1',
'user-1',
supabase as never,
{ type: 'api_key' }
)) as Record<string, unknown>
expect('skatteverket_connection' in result).toBe(false)
})
it('reports an active personal session with its connect time (the ~65 min fuse)', async () => {
const supabase = mockSupabase({
profile: null,
skvTokenRows: [{ user_id: 'user-1', status: 'active', created_at: '2026-08-25T09:12:00Z' }],
})
const result = (await tool().execute(
{},
'company-1',
'user-1',
supabase as never,
{ type: 'api_key' }
)) as {
skatteverket_connection?: {
status: string
source: string
connected_at?: string | null
message?: string
}
}
expect(result.skatteverket_connection).toEqual({
status: 'active',
source: 'user',
connected_at: '2026-08-25T09:12:00Z',
})
})
it('reports needs_reconsent with the directive message so the agent warns the user up front', async () => {
const supabase = mockSupabase({
profile: null,
skvTokenRows: [
{ user_id: 'user-1', status: 'needs_reconsent', created_at: '2026-08-25T08:00:00Z' },
],
})
const result = (await tool().execute(
{},
'company-1',
'user-1',
supabase as never,
{ type: 'api_key' }
)) as {
skatteverket_connection?: { status: string; source: string; message?: string }
}
expect(result.skatteverket_connection).toMatchObject({
status: 'needs_reconsent',
source: 'user',
})
expect(result.skatteverket_connection?.message).toContain('BankID')
expect(result.skatteverket_connection?.message).toContain('ca 1 timme')
})
it('declares the block in the outputSchema as optional (never required)', () => {
const output = tool().outputSchema as {
properties: Record<string, unknown>
required: string[]
}
expect(output.properties.skatteverket_connection).toBeDefined()
expect(output.required).not.toContain('skatteverket_connection')
})
})
describe('recommended_tools workflow loadouts (issue #1098)', () => {
it('every tool name in every loadout exists in the actual tool registry (drift guard)', () => {
const known = new Set(tools.map((t) => t.name))
@@ -203,9 +203,15 @@ describe('tools/list payload size guard', () => {
// silently, and agents inferred "consumed" from status 'failed' both
// ways. The enum is the contract; the description is one clause;
// headroom before the change was ~15 tokens, so even that crossed.
// * 60K to 60.2K with skatteverket_connection on the briefing: the
// connection-health block (status/source/connected_at) that lets an
// agent warn the user about a dead 65-minute SKV session at session
// start instead of mid-task. The runtime block is emitted only for
// companies with a connection; this cost is the outputSchema contract
// (~140 tokens), already trimmed to two short description strings.
// Long-term answer to growth is leaning harder on gnubok_search_tools: if this
// fires again, prefer trimming descriptions or making a tool opt-in via search
// before bumping further.
expect(approxTokens).toBeLessThan(60_000)
expect(approxTokens).toBeLessThan(60_200)
})
})
+63
View File
@@ -260,6 +260,8 @@ import { readAgiSubmissionStatus } from '@/extensions/general/skatteverket/lib/a
import { buildMomsuppgift, resolveRedovisare } from '@/extensions/general/skatteverket/lib/declaration-prep'
import { writeSkatteverketAudit } from '@/extensions/general/skatteverket/lib/audit'
import { skvAuthCodeToStructured } from '@/extensions/general/skatteverket/lib/error-map'
import { findCompanyTokenUser, hasVerifiedGrant } from '@/extensions/general/skatteverket/lib/resolve-auth'
import { getSystemAuthMode, isSystemAuthConfigured } from '@/extensions/general/skatteverket/lib/system-auth/config'
import { formatRedovisningsperiod } from '@/lib/skatteverket/format'
import { createExtensionContext } from '@/lib/extensions/context-factory'
import { commitPendingOperation } from '@/lib/pending-operations/commit'
@@ -3812,6 +3814,22 @@ export const tools: McpTool[] = [
},
required: ['tool', 'when', 'include'],
},
skatteverket_connection: {
type: 'object',
additionalProperties: false,
description:
'Present only when a Skatteverket connection exists. needs_reconsent: only a person can fix it (BankID under Inställningar → Skatteverket); warn the user before starting SKV work.',
properties: {
status: { type: 'string', enum: ['active', 'needs_reconsent'] },
source: { type: 'string', enum: ['user', 'system'] },
connected_at: {
type: ['string', 'null'],
description: 'Personal sessions last ~65 min from this time; absent for system (ombud) connections.',
},
message: { type: 'string' },
},
required: ['status', 'source'],
},
},
required: ['company', 'user_name', 'profile_summary', 'atoms', 'memory', 'recommended_tools'],
},
@@ -3876,6 +3894,49 @@ export const tools: McpTool[] = [
}
})()
// Skatteverket connection health, so the agent warns the user at
// session start instead of discovering a dead session mid-task.
// Best-effort and emitted only when a connection (or verified system
// grant) exists: never-connected companies pay no payload for it.
// The system-before-user priority mirrors resolveReadAuth
// (skatteverket/lib/resolve-auth.ts); not reused directly because the
// briefing needs token metadata (createdAt, reconsent status) that
// resolveReadAuth deliberately collapses into an auth result.
const safeSkvConnection = (async (): Promise<
| {
status: 'active' | 'needs_reconsent'
source: 'user' | 'system'
connected_at?: string | null
message?: string
}
| null
> => {
try {
if (process.env.SKATTEVERKET_ENABLED !== 'true') return null
if (
getSystemAuthMode() === 'on' &&
isSystemAuthConfigured() &&
(await hasVerifiedGrant(companyId, 'lasombud'))
) {
return { status: 'active', source: 'system' }
}
const token = await findCompanyTokenUser(supabase, companyId)
if (!token) return null
if (token.needsReconsent) {
return {
status: 'needs_reconsent',
source: 'user',
connected_at: token.createdAt,
message:
'Skatteverket-sessionen har gått ut. Skatteverkets personliga inloggning gäller bara ca 1 timme, så detta är normalt. Be användaren ansluta igen med BankID under Inställningar → Skatteverket; bara en person kan göra det, så försök inte med Skatteverket-verktyg förrän användaren bekräftat.',
}
}
return { status: 'active', source: 'user', connected_at: token.createdAt }
} catch {
return null
}
})()
const [profileRes, memoryRes, userRes, companyRes, settingsRes, dimensionsRes] = await Promise.all([
supabase
.from('agent_profiles')
@@ -4068,6 +4129,7 @@ export const tools: McpTool[] = [
}
const ledgerDigest = await safeLedgerDigest
const skvConnection = await safeSkvConnection
return {
company,
@@ -4083,6 +4145,7 @@ export const tools: McpTool[] = [
})),
...(dimensionsBlock ? { dimensions: dimensionsBlock } : {}),
...(ledgerDigest ? { ledger_context: ledgerDigest } : {}),
...(skvConnection ? { skatteverket_connection: skvConnection } : {}),
// Static per-workflow loadouts (issue #1098): lets a deferred-loading
// harness batch-load a whole workflow cluster in one call. Validated
// against the tool registry at module init (assertRecommendedLoadoutsValid).
@@ -1,230 +0,0 @@
/**
* Connection-expired email: the atomic claim-then-send dedup.
*
* One email per consent episode: the reference key is derived from
* (userId, token.created_at), claimed through notification_log under type
* 'skv_connection_expired' (partial unique index from migration
* 20260720090000). A re-observed expiry of the same token never notifies
* twice; a reconnect (new token row, new created_at) resets the episode.
*/
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { createHash } from 'crypto'
import type { SupabaseClient } from '@supabase/supabase-js'
const mockIsConfigured = vi.fn()
const mockSendEmail = vi.fn()
vi.mock('@/lib/email/service', () => ({
getEmailService: () => ({ isConfigured: mockIsConfigured, sendEmail: mockSendEmail }),
}))
import { sendConnectionExpiredNotification } from '../lib/connection-expired-notification'
interface RecordedOp {
table: string
op: 'select' | 'insert' | 'delete'
payload?: Record<string, unknown>
filters: Record<string, unknown>
}
const TOKEN_CREATED_AT = '2026-06-30T13:09:50.729013+00:00'
function expectedReferenceUuid(userId: string, createdAt: string): string {
const hex = createHash('sha256')
.update(`skv-connection-expired|${userId}|${createdAt}`)
.digest('hex')
return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20, 32)}`
}
/**
* Hand-rolled mock (instead of createQueuedMockSupabase) because the
* assertions need the recorded operation ORDER and payloads: claim insert
* before send, delete filters on release.
*/
function makeSupabase(opts: {
token?: { created_at: string } | null
alreadyRow?: { id: string } | null
member?: Record<string, unknown> | null
insertError?: { code?: string; message: string } | null
} = {}) {
const ops: RecordedOp[] = []
const from = (table: string) => {
const call: RecordedOp = { table, op: 'select', filters: {} }
ops.push(call)
const builder: Record<string, unknown> = {}
Object.assign(builder, {
select: () => builder,
insert: (payload: Record<string, unknown>) => {
call.op = 'insert'
call.payload = payload
return Promise.resolve({ data: null, error: opts.insertError ?? null })
},
delete: () => {
call.op = 'delete'
return builder
},
eq: (key: string, value: unknown) => {
call.filters[key] = value
return builder
},
maybeSingle: async () => {
if (table === 'skatteverket_tokens') {
const token =
opts.token === undefined ? { created_at: TOKEN_CREATED_AT } : opts.token
return { data: token, error: null }
}
if (table === 'notification_log') return { data: opts.alreadyRow ?? null, error: null }
if (table === 'company_members') {
const member =
opts.member === undefined
? { user_id: 'user-1', profiles: { email: 'user@example.com' } }
: opts.member
return { data: member, error: null }
}
return { data: null, error: null }
},
then: (resolve: (v: unknown) => void) => resolve({ data: null, error: null }),
})
return builder
}
return { supabase: { from } as unknown as SupabaseClient, ops }
}
const baseInput = { companyId: 'company-1', userId: 'user-1' }
beforeEach(() => {
vi.clearAllMocks()
mockIsConfigured.mockReturnValue(true)
mockSendEmail.mockResolvedValue({ success: true })
})
describe('sendConnectionExpiredNotification', () => {
it('claims the notification_log row BEFORE sending the email', async () => {
const { supabase, ops } = makeSupabase()
let claimsAtSendTime = -1
mockSendEmail.mockImplementation(async () => {
claimsAtSendTime = ops.filter((o) => o.op === 'insert').length
return { success: true }
})
const result = await sendConnectionExpiredNotification(supabase, baseInput)
expect(result).toEqual({ sent: true })
expect(claimsAtSendTime).toBe(1)
const insert = ops.find((o) => o.op === 'insert')
expect(insert?.table).toBe('notification_log')
expect(insert?.payload).toMatchObject({
user_id: 'user-1',
company_id: 'company-1',
notification_type: 'skv_connection_expired',
reference_id: expectedReferenceUuid('user-1', TOKEN_CREATED_AT),
delivery_status: 'sent',
})
})
it('sends the reconnect instructions to the token owner', async () => {
const { supabase } = makeSupabase()
await sendConnectionExpiredNotification(supabase, baseInput)
expect(mockSendEmail).toHaveBeenCalledTimes(1)
const mail = mockSendEmail.mock.calls[0][0] as {
to: string
subject: string
text: string
}
expect(mail.to).toBe('user@example.com')
expect(mail.subject).toContain('Skatteverket')
expect(mail.text).toContain('BankID')
expect(mail.text).toContain('alla behörigheter')
expect(mail.text).toContain('/settings/tax')
})
it('skips via the fast path when the episode was already notified', async () => {
const { supabase } = makeSupabase({ alreadyRow: { id: 'log-1' } })
const result = await sendConnectionExpiredNotification(supabase, baseInput)
expect(result).toEqual({ sent: false, reason: 'duplicate' })
expect(mockSendEmail).not.toHaveBeenCalled()
})
it('treats a 23505 unique violation on the claim as a duplicate', async () => {
const { supabase } = makeSupabase({
insertError: { code: '23505', message: 'duplicate key value' },
})
const result = await sendConnectionExpiredNotification(supabase, baseInput)
expect(result).toEqual({ sent: false, reason: 'duplicate' })
expect(mockSendEmail).not.toHaveBeenCalled()
})
it('skips the send when the claim insert fails for another reason', async () => {
const { supabase } = makeSupabase({
insertError: { code: '57014', message: 'canceling statement' },
})
const result = await sendConnectionExpiredNotification(supabase, baseInput)
expect(result).toEqual({ sent: false, reason: 'claim_failed' })
expect(mockSendEmail).not.toHaveBeenCalled()
})
it('does nothing when the email service is not configured', async () => {
mockIsConfigured.mockReturnValue(false)
const { supabase, ops } = makeSupabase()
const result = await sendConnectionExpiredNotification(supabase, baseInput)
expect(result).toEqual({ sent: false, reason: 'email_not_configured' })
expect(ops).toHaveLength(0)
expect(mockSendEmail).not.toHaveBeenCalled()
})
it('keys the episode on the (user, company) token row, not the user alone', async () => {
const { supabase, ops } = makeSupabase()
await sendConnectionExpiredNotification(supabase, baseInput)
// Rows are per (user, company): a multi-company operator holds several,
// and a user-only maybeSingle() errored out and silently skipped the
// email (#1673 follow-through).
const tokenRead = ops.find((o) => o.table === 'skatteverket_tokens')
expect(tokenRead?.filters).toMatchObject({ user_id: 'user-1', company_id: 'company-1' })
})
it('skips when no token row exists (already disconnected)', async () => {
const { supabase } = makeSupabase({ token: null })
const result = await sendConnectionExpiredNotification(supabase, baseInput)
expect(result).toEqual({ sent: false, reason: 'no_token' })
expect(mockSendEmail).not.toHaveBeenCalled()
})
it('skips without claiming when the token owner is no longer a member', async () => {
const { supabase, ops } = makeSupabase({ member: null })
const result = await sendConnectionExpiredNotification(supabase, baseInput)
expect(result).toEqual({ sent: false, reason: 'no_recipient' })
expect(ops.find((o) => o.op === 'insert')).toBeUndefined()
expect(mockSendEmail).not.toHaveBeenCalled()
})
it('releases the claim when the email send reports failure', async () => {
mockSendEmail.mockResolvedValue({ success: false, error: 'smtp down' })
const { supabase, ops } = makeSupabase()
const result = await sendConnectionExpiredNotification(supabase, baseInput)
expect(result).toEqual({ sent: false, reason: 'send_failed' })
const del = ops.find((o) => o.op === 'delete')
expect(del?.table).toBe('notification_log')
expect(del?.filters).toMatchObject({
user_id: 'user-1',
notification_type: 'skv_connection_expired',
reference_id: expectedReferenceUuid('user-1', TOKEN_CREATED_AT),
})
})
})
@@ -56,13 +56,15 @@ function makeSupabase(opts: {
},
maybeSingle: async () => {
if (table === 'notification_log') return { data: opts.alreadyRow ?? null, error: null }
// Two-step recipient lookup (lib/notifications/member-email): the
// membership check first, then the profile email.
if (table === 'company_members') {
const member =
opts.member === undefined
? { user_id: 'user-1', profiles: { email: 'user@example.com' } }
: opts.member
const member = opts.member === undefined ? { user_id: 'user-1' } : opts.member
return { data: member, error: null }
}
if (table === 'profiles') {
return { data: { email: 'user@example.com' }, error: null }
}
return { data: null, error: null }
},
then: (resolve: (v: unknown) => void) => resolve({ data: null, error: null }),
@@ -256,15 +256,18 @@ describe('findCompanyTokenUser', () => {
expect(await findCompanyTokenUser(supabase, 'company-1')).toEqual({
userId: 'user-b',
needsReconsent: false,
createdAt: '2',
})
expect(await findCompanyTokenUser(supabase, 'company-1', { preferUserId: 'user-a' })).toEqual({
userId: 'user-a',
needsReconsent: false,
createdAt: '1',
})
// Preferring a user whose row is dead still yields the live row.
expect(await findCompanyTokenUser(supabase, 'company-1', { preferUserId: 'user-c' })).toEqual({
userId: 'user-b',
needsReconsent: false,
createdAt: '2',
})
})
})
@@ -9,7 +9,6 @@
*/
import { describe, it, expect, vi, beforeEach } from 'vitest'
import type { SupabaseClient } from '@supabase/supabase-js'
import type { ExtensionContext } from '@/lib/extensions/types'
import type { EventPayload } from '@/lib/events/types'
const { warnRecorder } = vi.hoisted(() => ({ warnRecorder: vi.fn() }))
@@ -33,6 +32,17 @@ 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 {
@@ -41,26 +51,30 @@ interface QueryRecord {
filters: Record<string, unknown>
}
type MemberRow = { user_id: string; profiles: { email: string } }
type MemberRow = { user_id: string; email: string }
const OWNER: MemberRow = { user_id: 'user-1', profiles: { email: 'owner@example.com' } }
const ACCOUNTANT: MemberRow = { user_id: 'user-2', profiles: { email: 'revisor@byra.se' } }
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
profile?: { email: string } | null
profileError?: { message: string } | null
} = {},
) {
const members = opts.members === undefined ? [OWNER] : opts.members ?? []
const queries: QueryRecord[] = []
const from = (table: string) => {
const record: QueryRecord = { table, filters: {} }
@@ -68,7 +82,7 @@ function makeSupabase(
const result = () => {
if (table === 'company_members') {
return {
data: opts.members === undefined ? [OWNER] : opts.members,
data: opts.membersError ? null : members.map((m) => ({ user_id: m.user_id })),
error: opts.membersError ?? null,
}
}
@@ -86,8 +100,10 @@ function makeSupabase(
}
if (table === 'profiles') {
return {
data: opts.profile === undefined ? { email: OWNER.profiles.email } : opts.profile,
error: opts.profileError ?? null,
data: opts.profilesError
? null
: members.map((m) => ({ id: m.user_id, email: m.email })),
error: opts.profilesError ?? null,
}
}
return { data: null, error: null }
@@ -102,7 +118,17 @@ function makeSupabase(
record.filters[key] = value
return builder
},
maybeSingle: async () => result(),
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
@@ -110,13 +136,9 @@ function makeSupabase(
return { supabase: { from } as unknown as SupabaseClient, queries }
}
function makeCtx(supabase: SupabaseClient): ExtensionContext {
return {
userId: 'user-1',
companyId: 'company-1',
extensionId: 'skatteverket',
supabase,
} as unknown as ExtensionContext
/** Point the mocked createServiceClient at this test's supabase stub. */
function useServiceClient(supabase: SupabaseClient): void {
serviceClientHolder.current = supabase
}
const payload: EventPayload<'skattekonto.drift_detected'> = {
@@ -149,22 +171,24 @@ 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.profiles.email },
settings: { tax_contact_email: ACCOUNTANT.email },
})
await handleSkattekontoDriftDetected(payload, makeCtx(supabase))
useServiceClient(supabase)
await handleSkattekontoDriftDetected(payload)
expect(mockSendEmail).toHaveBeenCalledTimes(1)
expect(sentTo()).toBe(ACCOUNTANT.profiles.email)
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.profiles.email },
settings: { tax_contact_email: ACCOUNTANT.email },
})
await handleSkattekontoDriftDetected(payload, makeCtx(supabase))
useServiceClient(supabase)
await handleSkattekontoDriftDetected(payload)
const settingsQuery = queries.find((q) => q.table === 'company_settings')
// Exact match, not `toContain`: 'tax_contact_email' contains the phantom
@@ -179,7 +203,8 @@ describe('handleSkattekontoDriftDetected recipient resolution', () => {
settings: { tax_contact_email: 'Revisor@Byra.se' },
})
await handleSkattekontoDriftDetected(payload, makeCtx(supabase))
useServiceClient(supabase)
await handleSkattekontoDriftDetected(payload)
expect(sentTo()).toBe('Revisor@Byra.se')
})
@@ -187,9 +212,10 @@ describe('handleSkattekontoDriftDetected recipient resolution', () => {
it('falls back to the syncing user when no tax contact is configured', async () => {
const { supabase } = makeSupabase({ members: [OWNER], settings: null })
await handleSkattekontoDriftDetected(payload, makeCtx(supabase))
useServiceClient(supabase)
await handleSkattekontoDriftDetected(payload)
expect(sentTo()).toBe(OWNER.profiles.email)
expect(sentTo()).toBe(OWNER.email)
})
it('refuses a tax contact that is not an active member, and says so', async () => {
@@ -198,9 +224,10 @@ describe('handleSkattekontoDriftDetected recipient resolution', () => {
settings: { tax_contact_email: 'ex-admin@example.com' },
})
await handleSkattekontoDriftDetected(payload, makeCtx(supabase))
useServiceClient(supabase)
await handleSkattekontoDriftDetected(payload)
expect(sentTo()).toBe(OWNER.profiles.email)
expect(sentTo()).toBe(OWNER.email)
expect(warnedWith('not an active member')).toBe(true)
})
@@ -211,11 +238,12 @@ describe('handleSkattekontoDriftDetected recipient resolution', () => {
settingsError: { message: 'column company_settings.tax_contact_email does not exist' },
})
await handleSkattekontoDriftDetected(payload, makeCtx(supabase))
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.profiles.email)
expect(sentTo()).toBe(OWNER.email)
})
it('does not silently swallow a member lookup error', async () => {
@@ -224,30 +252,32 @@ describe('handleSkattekontoDriftDetected recipient resolution', () => {
membersError: { message: 'permission denied for table company_members' },
})
await handleSkattekontoDriftDetected(payload, makeCtx(supabase))
useServiceClient(supabase)
await handleSkattekontoDriftDetected(payload)
expect(warnedWith('could not read company members')).toBe(true)
expect(mockSendEmail).not.toHaveBeenCalled()
})
it('does not silently swallow a profile lookup error on the fallback path', async () => {
it('does not silently swallow a member-email lookup error', async () => {
const { supabase } = makeSupabase({
members: [OWNER],
settings: null,
profile: null,
profileError: { message: 'timeout' },
profilesError: { message: 'timeout' },
})
await handleSkattekontoDriftDetected(payload, makeCtx(supabase))
useServiceClient(supabase)
await handleSkattekontoDriftDetected(payload)
expect(warnedWith('could not read syncing user profile')).toBe(true)
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: [] })
await handleSkattekontoDriftDetected(payload, makeCtx(supabase))
useServiceClient(supabase)
await handleSkattekontoDriftDetected(payload)
expect(mockSendEmail).not.toHaveBeenCalled()
expect(warnedWith('no authorised recipient')).toBe(true)
@@ -257,7 +287,8 @@ describe('handleSkattekontoDriftDetected recipient resolution', () => {
mockIsConfigured.mockReturnValue(false)
const { supabase, queries } = makeSupabase({ members: [OWNER, ACCOUNTANT] })
await handleSkattekontoDriftDetected(payload, makeCtx(supabase))
useServiceClient(supabase)
await handleSkattekontoDriftDetected(payload)
expect(mockSendEmail).not.toHaveBeenCalled()
expect(queries).toHaveLength(0)
@@ -266,10 +297,11 @@ describe('handleSkattekontoDriftDetected recipient resolution', () => {
it('keeps the drift figures out of the email body', async () => {
const { supabase } = makeSupabase({
members: [OWNER, ACCOUNTANT],
settings: { tax_contact_email: ACCOUNTANT.profiles.email },
settings: { tax_contact_email: ACCOUNTANT.email },
})
await handleSkattekontoDriftDetected(payload, makeCtx(supabase))
useServiceClient(supabase)
await handleSkattekontoDriftDetected(payload)
const body = `${mockSendEmail.mock.calls[0][0].text}${mockSendEmail.mock.calls[0][0].html}`
expect(body).not.toContain('1250')
+6 -6
View File
@@ -55,7 +55,6 @@ import {
SkattekontoBookingError,
} from './lib/skattekonto-booking'
import { handleSkattekontoDriftDetected } from './lib/skattekonto-drift-email'
import { handleSkattekontoConnectionExpired } from './lib/connection-expired-notification'
import {
findMatchCandidates,
findMatchSuggestionsBulk,
@@ -196,7 +195,8 @@ function readAuthFailureResponse(reason: 'no_token' | 'needs_reconsent'): NextRe
if (reason === 'needs_reconsent') {
return NextResponse.json(
{
error: 'Anslutningen mot Skatteverket behöver förnyas. Anslut igen med BankID.',
error:
'Anslutningen mot Skatteverket har gått ut. Skatteverkets inloggning gäller bara ca 1 timme, så detta är normalt. Anslut igen med BankID.',
code: 'SESSION_EXPIRED',
},
{ status: 401 },
@@ -2483,15 +2483,15 @@ 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,
},
{
eventType: 'skattekonto.connection.expired',
handler: handleSkattekontoConnectionExpired,
},
],
// Registry-resolved commit services for the MCP submit tools. The core
@@ -1,246 +0,0 @@
/**
* Connection-expired notification (email).
*
* Handler for `skattekonto.connection.expired`: emitted by syncSkattekonto
* when a terminal auth error (REFRESH_EXHAUSTED / SESSION_EXPIRED /
* TOKEN_CORRUPTED) kills the personal SKV token. Before this handler the
* event was emitted but unconsumed: the needs_reconsent state was only
* visible on the settings panel, which users have no reason to revisit, so
* a dead connection silently meant "skattekonto never syncs again". Prod
* accumulated ~70 companies in exactly that state.
*
* Email is the delivery channel (same rationale as kvittens-notification:
* push-notifications is a separate, optional extension and cross-extension
* imports are not allowed). The recipient is the token OWNER: only they can
* redo the BankID consent, so a company contact address would be actionable
* for the wrong person. The owner must still be an active member of the
* company.
*
* Deduplication: one email per consent episode. The reference key is derived
* from (userId, token.created_at): a reconnect creates a new token row with
* a new created_at, so a later expiry notifies again, while the nightly cron
* re-observing the same dead token stays silent. Claim-first through
* notification_log under type 'skv_connection_expired', made atomic by the
* partial unique index in migration 20260720090000 (mirrors the kvittens
* pattern).
*
* Best-effort by contract: a notification failure must never fail the sync
* (or cron) that observed the expiry. The body carries no financial data.
*/
import { createHash } from 'crypto'
import type { SupabaseClient } from '@supabase/supabase-js'
import type { EventPayload } from '@/lib/events/types'
import { createServiceClient } from '@/lib/supabase/server'
import { getEmailService } from '@/lib/email/service'
import { createLogger } from '@/lib/logger'
const log = createLogger('skv-connection-expired-notification')
export async function handleSkattekontoConnectionExpired(
payload: EventPayload<'skattekonto.connection.expired'>,
): Promise<void> {
// Service-role client, NOT the registry-built ctx: the primary emitter is
// the nightly cron, whose request carries no user cookies, so the ctx the
// registry lazily builds there is an anonymous client that RLS turns into
// "no token, no member, nothing to do". Same rationale as the
// document-extraction handler.
const supabase = createServiceClient()
await sendConnectionExpiredNotification(supabase, {
companyId: payload.companyId,
userId: payload.userId,
})
}
export interface ConnectionExpiredInput {
companyId: string
/** The token-owning user: the only person who can redo the BankID consent. */
userId: string
}
export async function sendConnectionExpiredNotification(
supabase: SupabaseClient,
input: ConnectionExpiredInput,
): Promise<{ sent: boolean; reason?: string }> {
try {
const email = getEmailService()
if (!email.isConfigured()) return { sent: false, reason: 'email_not_configured' }
// Episode key: the token row's created_at. No token row means nothing to
// reconnect (already disconnected): skip. Rows are per (user, company):
// without the company filter a multi-company operator's second row made
// maybeSingle() error out and the email was never sent.
const { data: token } = await supabase
.from('skatteverket_tokens')
.select('created_at')
.eq('user_id', input.userId)
.eq('company_id', input.companyId)
.maybeSingle()
const tokenCreatedAt = (token as { created_at?: string | null } | null)?.created_at
if (!tokenCreatedAt) return { sent: false, reason: 'no_token' }
const referenceUuid = toReferenceUuid(
`skv-connection-expired|${input.userId}|${tokenCreatedAt}`,
)
// Dedup fast path: cheap read that skips the work below on re-observed
// expiries. NOT the enforcement: the claim insert further down is.
const { data: already } = await supabase
.from('notification_log')
.select('id')
.eq('user_id', input.userId)
.eq('notification_type', 'skv_connection_expired')
.eq('reference_id', referenceUuid)
.maybeSingle()
if (already) return { sent: false, reason: 'duplicate' }
const recipient = await resolveMemberEmail(supabase, input.companyId, input.userId)
if (!recipient) {
log.info('no authorised recipient for connection-expired email', {
companyId: input.companyId,
})
return { sent: false, reason: 'no_recipient' }
}
// Claim before sending: the partial unique index on notification_log
// (user_id, reference_id) where notification_type =
// 'skv_connection_expired' makes this atomic. Of two overlapping
// emitters exactly one wins the insert; the loser gets a unique
// violation and skips the send.
const { error: claimError } = await supabase.from('notification_log').insert({
user_id: input.userId,
company_id: input.companyId,
notification_type: 'skv_connection_expired',
reference_id: referenceUuid,
days_before: 0,
delivery_status: 'sent',
})
if (claimError) {
if (claimError.code === '23505') return { sent: false, reason: 'duplicate' }
// Without a claim we cannot guarantee single delivery: skip the send
// (fail closed on the never-twice guarantee) and let a later
// observation retry.
log.warn('connection-expired claim insert failed', {
companyId: input.companyId,
error: claimError.message,
})
return { sent: false, reason: 'claim_failed' }
}
const appUrl = (process.env.NEXT_PUBLIC_APP_URL || 'https://gnubok.se').replace(/\/$/, '')
const settingsLink = `${appUrl}/settings/tax`
const subject = 'Anslutningen till Skatteverket behöver förnyas'
const text = [
'Din anslutning till Skatteverket har gått ut. Hämtningen av skattekontots saldo och transaktioner är pausad tills du ansluter igen.',
'',
'Så här återansluter du:',
`1. Gå till Inställningar > Skatt i Accounted: ${settingsLink}`,
'2. Klicka på "Anslut igen" och legitimera dig med BankID.',
'3. Godkänn alla behörigheter på Skatteverkets samtyckessida.',
'',
'När du anslutit hämtas skattekontot direkt.',
].join('\n')
const html = [
'<p>Din anslutning till Skatteverket har gått ut. Hämtningen av skattekontots saldo och transaktioner är pausad tills du ansluter igen.</p>',
'<p><strong>Så här återansluter du:</strong></p>',
'<ol>',
`<li>Gå till <a href="${escapeHtml(settingsLink)}">Inställningar &gt; Skatt i Accounted</a>.</li>`,
'<li>Klicka på "Anslut igen" och legitimera dig med BankID.</li>',
'<li>Godkänn alla behörigheter på Skatteverkets samtyckessida.</li>',
'</ol>',
'<p>När du anslutit hämtas skattekontot direkt.</p>',
].join('')
let result: Awaited<ReturnType<typeof email.sendEmail>>
try {
result = await email.sendEmail({ to: recipient, subject, text, html })
} catch (sendErr) {
// Release the claim so a later observation can retry the send.
await releaseClaim(supabase, input.userId, referenceUuid)
throw sendErr
}
if (!result.success) {
log.warn('connection-expired email send failed', {
companyId: input.companyId,
error: result.error,
})
await releaseClaim(supabase, input.userId, referenceUuid)
return { sent: false, reason: 'send_failed' }
}
return { sent: true }
} catch (err) {
log.warn('connection-expired notification failed', {
companyId: input.companyId,
error: err instanceof Error ? err.message : String(err),
})
return { sent: false, reason: 'error' }
}
}
/**
* notification_log.reference_id is a uuid column; the episode key is a
* composite string. Map it to a deterministic uuid-shaped SHA-256 digest so
* the same episode always resolves to the same claim row (same approach as
* kvittens-notification).
*/
function toReferenceUuid(referenceKey: string): string {
const hex = createHash('sha256').update(referenceKey).digest('hex')
return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20, 32)}`
}
/** Remove a claim whose email never went out, so a later run can retry. */
async function releaseClaim(
supabase: SupabaseClient,
userId: string,
referenceUuid: string,
): Promise<void> {
try {
await supabase
.from('notification_log')
.delete()
.eq('user_id', userId)
.eq('notification_type', 'skv_connection_expired')
.eq('reference_id', referenceUuid)
} catch (err) {
// A stuck claim only suppresses a retry of this one email: log and move on.
log.warn('failed to release connection-expired claim', {
userId,
referenceUuid,
error: err instanceof Error ? err.message : String(err),
})
}
}
/**
* The recipient must still be an active member of the company (mirrors the
* kvittens rule): a token owner who has since been removed from the company
* must not receive connection nudges for it.
*/
async function resolveMemberEmail(
supabase: SupabaseClient,
companyId: string,
userId: string,
): Promise<string | null> {
const { data: member } = await supabase
.from('company_members')
.select('user_id, profiles!inner(email)')
.eq('company_id', companyId)
.eq('user_id', userId)
.maybeSingle()
if (!member) return null
type ProfileRef = { email?: string | null } | { email?: string | null }[] | null
const profiles = (member as { profiles: ProfileRef }).profiles
const profile = Array.isArray(profiles) ? profiles[0] : profiles
return profile?.email ?? null
}
function escapeHtml(input: string): string {
return input
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;')
}
@@ -91,8 +91,8 @@ export async function fetchVatDeclarationStatus(
http_status: 401,
error:
resolved.reason === 'needs_reconsent'
? 'Anslutningen mot Skatteverket behöver förnyas. Anslut igen med BankID.'
: 'Inte ansluten till Skatteverket.',
? 'Anslutningen mot Skatteverket behöver förnyas: Skatteverkets personliga inloggning gäller bara ca 1 timme, så detta är normalt. Be användaren ansluta igen med BankID under Inställningar → Skatteverket. Bara en person kan göra det; försök inte igen förrän användaren bekräftat.'
: 'Inte ansluten till Skatteverket. Be användaren ansluta med BankID under Inställningar → Skatteverket.',
}
}
@@ -26,6 +26,7 @@ import { createHash } from 'crypto'
import type { SupabaseClient } from '@supabase/supabase-js'
import { getEmailService } from '@/lib/email/service'
import { createLogger } from '@/lib/logger'
import { resolveMemberEmail } from '@/lib/notifications/member-email'
const log = createLogger('skv-kvittens-notification')
@@ -172,30 +173,6 @@ async function releaseClaim(
}
}
/**
* The recipient must still be an active member of the company (mirrors the
* drift-email rule): a token owner who has since been removed from the
* company must not receive filing confirmations for it.
*/
async function resolveMemberEmail(
supabase: SupabaseClient,
companyId: string,
userId: string
): Promise<string | null> {
const { data: member } = await supabase
.from('company_members')
.select('user_id, profiles!inner(email)')
.eq('company_id', companyId)
.eq('user_id', userId)
.maybeSingle()
if (!member) return null
type ProfileRef = { email?: string | null } | { email?: string | null }[] | null
const profiles = (member as { profiles: ProfileRef }).profiles
const profile = Array.isArray(profiles) ? profiles[0] : profiles
return profile?.email ?? null
}
function escapeHtml(input: string): string {
return input
.replace(/&/g, '&amp;')
@@ -105,6 +105,12 @@ export async function resolveReadAuth(
export interface CompanyTokenUser {
userId: string
needsReconsent: boolean
/**
* When the serving row was issued (last consent or refresh; storeTokens is
* DELETE + INSERT). Personal SKV sessions live ~65 minutes from this time,
* so consumers (the agent briefing) can reason about likely expiry.
*/
createdAt: string | null
}
/**
@@ -141,8 +147,11 @@ export async function findCompanyTokenUser(
return null
}
const rows = ((data ?? []) as Array<{ user_id: string | null; status: string | null }>).filter(
(row): row is { user_id: string; status: string | null } => typeof row.user_id === 'string'
const rows = (
(data ?? []) as Array<{ user_id: string | null; status: string | null; created_at: string | null }>
).filter(
(row): row is { user_id: string; status: string | null; created_at: string | null } =>
typeof row.user_id === 'string'
)
if (rows.length === 0) return null
@@ -154,5 +163,6 @@ export async function findCompanyTokenUser(
return {
userId: pick.user_id,
needsReconsent: pick.status === 'needs_reconsent',
createdAt: pick.created_at ?? null,
}
}
@@ -1,5 +1,8 @@
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'
@@ -14,6 +17,12 @@ const log = createLogger('skattekonto-drift-email')
* (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
@@ -24,15 +33,8 @@ const log = createLogger('skattekonto-drift-email')
*/
export async function handleSkattekontoDriftDetected(
payload: EventPayload<'skattekonto.drift_detected'>,
ctx?: ExtensionContext,
_ctx?: ExtensionContext,
): Promise<void> {
if (!ctx) {
log.warn('drift event fired without ctx: cannot resolve recipient', {
companyId: payload.companyId,
})
return
}
const email = getEmailService()
if (!email.isConfigured()) {
log.info('email service not configured: skipping drift alert', {
@@ -41,7 +43,8 @@ export async function handleSkattekontoDriftDetected(
return
}
const recipient = await resolveAuthorisedRecipient(ctx, payload.userId)
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,
@@ -120,39 +123,24 @@ export async function handleSkattekontoDriftDetected(
* whoever happened to trigger the sync.
*/
async function resolveAuthorisedRecipient(
ctx: ExtensionContext,
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.
const { data: members, error: membersError } = await ctx.supabase
.from('company_members')
.select('user_id, profiles!inner(email)')
.eq('company_id', ctx.companyId)
if (membersError) {
// Without the allowlist every candidate is rejected below, so a failed
// lookup silently cancels the alert. Say so out loud.
log.warn('could not read company members for drift alert', {
companyId: ctx.companyId,
error: membersError.message,
})
}
type MemberRow = { user_id: string; profiles: { email?: string | null } | { email?: string | null }[] | null }
// 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 m of (members ?? []) as MemberRow[]) {
const profile = Array.isArray(m.profiles) ? m.profiles[0] : m.profiles
if (profile?.email) allowedEmails.add(profile.email.toLowerCase())
}
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 ctx.supabase
const { data: settings, error: settingsError } = await supabase
.from('company_settings')
.select('tax_contact_email')
.eq('company_id', ctx.companyId)
.eq('company_id', companyId)
.maybeSingle()
if (settingsError) {
@@ -160,7 +148,7 @@ async function resolveAuthorisedRecipient(
// 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: ctx.companyId,
companyId,
error: settingsError.message,
})
}
@@ -171,24 +159,13 @@ async function resolveAuthorisedRecipient(
return contactEmail
}
log.warn('configured tax contact is not an active member: falling back to syncing user', {
companyId: ctx.companyId,
companyId,
})
}
// 3. Fall back to the syncing user's email if they're still a member.
const { data: profile, error: profileError } = await ctx.supabase
.from('profiles')
.select('email')
.eq('id', userId)
.maybeSingle()
if (profileError) {
log.warn('could not read syncing user profile for drift alert', {
companyId: ctx.companyId,
userId,
error: profileError.message,
})
}
const userEmail = (profile as { email?: string | null } | null)?.email
// 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
}
+9 -3
View File
@@ -3473,14 +3473,20 @@ const SKATTEVERKET: Record<string, StructuredErrorEntry> = {
message_sv: 'Skatteverket-integrationen är inte aktiverad i denna miljö.',
message_en: 'The Skatteverket integration is not enabled in this environment.',
},
// One code covers both never-connected and expired: splitting it would
// ripple through every consumer, and the declaration-status path already
// differentiates in its message (DECISIONS.md 2026-08-25). The copy is
// agent-directive on purpose: only a person can run the BankID flow, so
// the agent must hand the task to the user instead of retrying.
SKATTEVERKET_NOT_CONNECTED: {
httpStatus: 401,
message_sv:
'Anslutningen till Skatteverket saknas eller har gått ut. Anslut med BankID under Inställningar → Skatteverket.',
message_en: 'No valid Skatteverket connection. Reconnect with BankID before retrying.',
'Anslutningen till Skatteverket saknas eller har gått ut. Om företaget varit anslutet tidigare är detta normalt: Skatteverkets personliga inloggning gäller bara ca 1 timme. Be användaren ansluta (igen) med BankID under Inställningar → Skatteverket.',
message_en:
'The Skatteverket connection is missing or has expired. If the company was connected before this is expected: Skatteverket personal sessions last only about 1 hour. Tell the user to connect (or reconnect) with BankID under Inställningar → Skatteverket in Accounted. Only a person can do this; do not retry until they confirm they have reconnected.',
remediation: {
description:
'Connect (or reconnect) to Skatteverket with BankID under Settings → Skatteverket, then retry.',
'A person must connect (or reconnect) to Skatteverket with BankID under Inställningar → Skatteverket. Personal Skatteverket sessions expire after about 1 hour by SKV design, so an expired session is normal, not a fault. Do not retry until the user confirms they have reconnected.',
},
},
SKATTEVERKET_ACCESS_DENIED: {
@@ -0,0 +1,199 @@
/**
* Recipient resolution for notification emails.
*
* The regression this guards: the four notification senders used the
* PostgREST embed company_members → profiles!inner(email), but no FK links
* those tables, so the embed 400'd and every recipient resolved to null.
* The helper resolves membership and email as two separate queries.
*/
import { describe, it, expect, vi, beforeEach } from 'vitest'
import type { SupabaseClient } from '@supabase/supabase-js'
const { warnRecorder } = vi.hoisted(() => ({ warnRecorder: vi.fn() }))
vi.mock('@/lib/logger', () => ({
createLogger: () => ({
info: vi.fn(),
warn: warnRecorder,
error: vi.fn(),
child() {
return this
},
}),
}))
import { resolveMemberEmail, resolveMemberEmails } from '../member-email'
interface QueryRecord {
table: string
columns?: string
filters: Record<string, unknown>
}
function makeSupabase(opts: {
memberRows?: Array<{ user_id: string | null }> | null
membersError?: { message: string } | null
profileRows?: Array<{ id: string; email: string | null }> | null
profilesError?: { message: string } | null
}) {
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 : opts.memberRows ?? [],
error: opts.membersError ?? null,
}
}
if (table === 'profiles') {
return {
data: opts.profilesError ? null : opts.profileRows ?? [],
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: rows?.[0] ?? null, error: r.error }
},
then: (resolve: (v: unknown) => void) => resolve(result()),
})
return builder
}
return { supabase: { from } as unknown as SupabaseClient, queries }
}
function warnedWith(fragment: string): boolean {
return warnRecorder.mock.calls.some(
(call) => typeof call[0] === 'string' && call[0].includes(fragment),
)
}
beforeEach(() => {
vi.clearAllMocks()
})
describe('resolveMemberEmail', () => {
it('resolves an active member to their profile email via two queries (no embed)', async () => {
const { supabase, queries } = makeSupabase({
memberRows: [{ user_id: 'user-1' }],
profileRows: [{ id: 'user-1', email: 'user@example.com' }],
})
const email = await resolveMemberEmail(supabase, 'company-1', 'user-1')
expect(email).toBe('user@example.com')
const memberQuery = queries.find((q) => q.table === 'company_members')
// The embed column list is exactly what broke: assert it is gone.
expect(memberQuery?.columns).toBe('user_id')
expect(memberQuery?.filters).toMatchObject({ company_id: 'company-1', user_id: 'user-1' })
const profileQuery = queries.find((q) => q.table === 'profiles')
expect(profileQuery?.columns).toBe('email')
expect(profileQuery?.filters).toMatchObject({ id: 'user-1' })
})
it('returns null for a user who is no longer a member (no profile query at all)', async () => {
const { supabase, queries } = makeSupabase({
memberRows: [],
profileRows: [{ id: 'user-1', email: 'user@example.com' }],
})
expect(await resolveMemberEmail(supabase, 'company-1', 'user-1')).toBeNull()
expect(queries.some((q) => q.table === 'profiles')).toBe(false)
})
it('returns null when the profile has no email', async () => {
const { supabase } = makeSupabase({
memberRows: [{ user_id: 'user-1' }],
profileRows: [{ id: 'user-1', email: null }],
})
expect(await resolveMemberEmail(supabase, 'company-1', 'user-1')).toBeNull()
})
it('logs and returns null (never throws) on a member query error', async () => {
const { supabase } = makeSupabase({
membersError: { message: 'permission denied for table company_members' },
})
expect(await resolveMemberEmail(supabase, 'company-1', 'user-1')).toBeNull()
expect(warnedWith('could not read company members')).toBe(true)
})
it('logs and returns null (never throws) on a profile query error', async () => {
const { supabase } = makeSupabase({
memberRows: [{ user_id: 'user-1' }],
profilesError: { message: 'timeout' },
})
expect(await resolveMemberEmail(supabase, 'company-1', 'user-1')).toBeNull()
expect(warnedWith('could not read profile email')).toBe(true)
})
})
describe('resolveMemberEmails', () => {
it('maps every member with a profile email, keyed by user id', async () => {
const { supabase, queries } = makeSupabase({
memberRows: [{ user_id: 'user-1' }, { user_id: 'user-2' }, { user_id: 'user-3' }],
profileRows: [
{ id: 'user-1', email: 'owner@example.com' },
{ id: 'user-2', email: 'revisor@byra.se' },
{ id: 'user-3', email: null },
],
})
const emails = await resolveMemberEmails(supabase, 'company-1')
expect(emails.get('user-1')).toBe('owner@example.com')
expect(emails.get('user-2')).toBe('revisor@byra.se')
expect(emails.has('user-3')).toBe(false)
const memberQuery = queries.find((q) => q.table === 'company_members')
expect(memberQuery?.columns).toBe('user_id')
const profileQuery = queries.find((q) => q.table === 'profiles')
expect(profileQuery?.columns).toBe('id, email')
expect(profileQuery?.filters).toMatchObject({ id: ['user-1', 'user-2', 'user-3'] })
})
it('returns an empty map for a company with no members (no profile query)', async () => {
const { supabase, queries } = makeSupabase({ memberRows: [] })
const emails = await resolveMemberEmails(supabase, 'company-1')
expect(emails.size).toBe(0)
expect(queries.some((q) => q.table === 'profiles')).toBe(false)
})
it('logs and returns an empty map on a member query error', async () => {
const { supabase } = makeSupabase({
membersError: { message: 'permission denied for table company_members' },
})
const emails = await resolveMemberEmails(supabase, 'company-1')
expect(emails.size).toBe(0)
expect(warnedWith('could not read company members')).toBe(true)
})
it('logs and returns an empty map on a profiles query error', async () => {
const { supabase } = makeSupabase({
memberRows: [{ user_id: 'user-1' }],
profilesError: { message: 'timeout' },
})
const emails = await resolveMemberEmails(supabase, 'company-1')
expect(emails.size).toBe(0)
expect(warnedWith('could not read member emails')).toBe(true)
})
})
+139
View File
@@ -0,0 +1,139 @@
/**
* Recipient resolution for notification emails.
*
* Every outbound notification restricts its recipient to active members of
* the company: a token owner, schedule owner, or configured contact who has
* since been removed must never receive company mail (the bare existence of
* some notifications is sensitive financial signal).
*
* The lookup is deliberately two queries. `company_members.user_id`
* references auth.users, not public.profiles, so the PostgREST embed
* `profiles!inner(email)` has no foreign-key relationship to traverse and
* fails the whole query with a 400. That embed shipped in four notification
* senders and silently killed all of them: the recipient resolved to null
* and every mail was skipped as "no recipient". Adding the FK instead would
* mean a migration on a core tenancy table for zero functional gain
* (see DECISIONS.md 2026-08-25).
*
* Both functions are best-effort and never throw: a failed lookup logs and
* resolves to "no recipient", because notification delivery must never fail
* the sync/cron/reconciliation that triggered it. Logging alone is not the
* safety net (the embed bug WAS logged by one caller and nobody read it):
* callers verify delivery end to end after deploy.
*
* SERVICE-ROLE CLIENT REQUIRED. Under an RLS user client these lookups
* silently degrade: company_members is readable company-wide, but the
* profiles SELECT policy is own-row-only, so resolveMemberEmails would
* return at most the caller's own email and resolveMemberEmail(other user)
* always null. Every current caller is a service-role cron path; keep it
* that way or widen the profiles policy first.
*/
import type { SupabaseClient } from '@supabase/supabase-js'
import { createLogger } from '@/lib/logger'
import { fetchAllRows } from '@/lib/supabase/fetch-all'
const log = createLogger('member-email')
/**
* Resolve one user's email, only if they are still an active member of the
* company. Returns null (never throws) when the user is not a member, has no
* profile email, or a query fails.
*/
export async function resolveMemberEmail(
supabase: SupabaseClient,
companyId: string,
userId: string
): Promise<string | null> {
const { data: member, error: memberError } = await supabase
.from('company_members')
.select('user_id')
.eq('company_id', companyId)
.eq('user_id', userId)
.maybeSingle()
if (memberError) {
log.warn('could not read company members for notification recipient', {
companyId,
userId,
error: memberError.message,
})
return null
}
if (!member) return null
const { data: profile, error: profileError } = await supabase
.from('profiles')
.select('email')
.eq('id', userId)
.maybeSingle()
if (profileError) {
log.warn('could not read profile email for notification recipient', {
companyId,
userId,
error: profileError.message,
})
return null
}
return (profile as { email?: string | null } | null)?.email ?? null
}
/**
* Resolve every active member's email for a company, as a map of
* user_id → email. Used by senders that route to a configured contact
* address but must verify it against the member allowlist (skattekonto
* drift alert). Returns an empty map (never throws) on any query failure,
* which callers treat as "no authorised recipient".
*/
export async function resolveMemberEmails(
supabase: SupabaseClient,
companyId: string
): Promise<Map<string, string>> {
const emails = new Map<string, string>()
// fetchAllRows with stable ordering: PostgREST silently caps unpaged
// reads at 1000 rows, which would drop members from the allowlist.
let members: Array<{ user_id: string | null }>
try {
members = await fetchAllRows(({ from, to }) =>
supabase
.from('company_members')
.select('user_id')
.eq('company_id', companyId)
.order('user_id', { ascending: true })
.range(from, to)
)
} catch (err) {
log.warn('could not read company members for notification allowlist', {
companyId,
error: err instanceof Error ? err.message : String(err),
})
return emails
}
const userIds = members
.map((m) => m.user_id)
.filter((id): id is string => typeof id === 'string')
if (userIds.length === 0) return emails
let profiles: Array<{ id: string; email: string | null }>
try {
profiles = await fetchAllRows(({ from, to }) =>
supabase
.from('profiles')
.select('id, email')
.in('id', userIds)
.order('id', { ascending: true })
.range(from, to)
)
} catch (err) {
log.warn('could not read member emails for notification allowlist', {
companyId,
error: err instanceof Error ? err.message : String(err),
})
return emails
}
for (const row of profiles) {
if (row.email) emails.set(row.id, row.email)
}
return emails
}
+1 -1
View File
@@ -514,7 +514,7 @@ Fetches the momsdeklaration for one period as Skatteverket has it on file: `subm
**Do not use for:** Computing the declaration from the books (use the VAT report), or filing: submission is a separate BankID-signed flow.
**Pitfalls:**
- This is a live Skatteverket read: it fails with SKATTEVERKET_NOT_CONNECTED (401) until someone in the company has connected with BankID under Installningar, and the response reflects SKV's state, not the books.
- This is a live Skatteverket read: it fails with SKATTEVERKET_NOT_CONNECTED (401) when the company has neither a member's BankID connection (made under Installningar) nor a verified ombud grant, and the response reflects SKV's state, not the books. Personal BankID sessions expire after ~1 hour by design, so an expired connection is normal: ask the user to reconnect; only a person can, so do not retry until they confirm.
- submitted=null and decided=null with HTTP 200 means "nothing on file for the period": it is not an error.
- A submitted declaration can lack a beslut for days: poll decided separately rather than assuming both appear together.
- redovisningsperiod is SKV's YYYYMM format (the period's LAST month): quarterly period 1 is 03, not 01.