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:
co-authored by
Claude Fable 5
parent
f929b4b1d2
commit
1fa34aa7ca
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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 > 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, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''')
|
||||
}
|
||||
@@ -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, '&')
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user