* 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>
180 lines
7.1 KiB
TypeScript
180 lines
7.1 KiB
TypeScript
import type { SupabaseClient } from '@supabase/supabase-js'
|
|
import { formatRedovisningsperiod } from '@/lib/skatteverket/format'
|
|
import { resolvePeriodDates } from '@/lib/reports/vat-declaration'
|
|
import { createExtensionContext } from '@/lib/extensions/context-factory'
|
|
import type {
|
|
SkvVatDeclarationStatusInput,
|
|
SkvVatDeclarationStatusResult,
|
|
} from '@/lib/skatteverket/declaration-status'
|
|
import { skvRequestWithAuth, SkatteverketAuthError } from './api-client'
|
|
import { resolveReadAuth } from './resolve-auth'
|
|
import { resolveRedovisare } from './declaration-prep'
|
|
import { skvAuthCodeToStructured } from './error-map'
|
|
import { writeSkatteverketAudit } from './audit'
|
|
|
|
/**
|
|
* Registry-resolved read service for filed momsdeklarationer (issue #1663).
|
|
*
|
|
* Fetches Skatteverket's /inlamnat (the declaration as submitted) and/or
|
|
* /beslutat (the beslut) views for one period, so API consumers (v1 REST) can
|
|
* compare their books against actually-filed data. Read-only on SKV's side;
|
|
* the only local writes are the regulator audit rows.
|
|
*
|
|
* Auth follows the company-scoped read model (#1673, resolve-auth.ts): the
|
|
* caller's own token when they connected, otherwise any other member's active
|
|
* token, otherwise system credentials with a verified ombud grant. The fetched
|
|
* declaration belongs to the company, not to whoever pressed "Anslut".
|
|
*
|
|
* Error contract: known failure modes return `{ ok: false, code, http_status,
|
|
* error }` with structured codes so the v1 route maps them deterministically;
|
|
* unexpected errors are thrown and handled by the route wrapper.
|
|
*/
|
|
export async function fetchVatDeclarationStatus(
|
|
supabase: SupabaseClient,
|
|
userId: string,
|
|
companyId: string,
|
|
input: SkvVatDeclarationStatusInput,
|
|
): Promise<SkvVatDeclarationStatusResult> {
|
|
// Direct service calls bypass the HTTP dispatcher's SKATTEVERKET_ENABLED
|
|
// gate (app/api/extensions/ext/[...path]/route.ts), so check the flag here:
|
|
// same reasoning as the commit services in index.ts.
|
|
if (process.env.SKATTEVERKET_ENABLED !== 'true') {
|
|
return {
|
|
ok: false,
|
|
code: 'EXTENSION_DISABLED',
|
|
http_status: 503,
|
|
error: 'Skatteverket-integrationen är inte aktiverad i denna miljö.',
|
|
}
|
|
}
|
|
|
|
const state = input.state ?? 'both'
|
|
const ctx = createExtensionContext(supabase, userId, companyId, 'skatteverket')
|
|
|
|
let redovisare: string
|
|
try {
|
|
redovisare = await resolveRedovisare(supabase, companyId)
|
|
} catch (err) {
|
|
// Missing org number in company settings: a configuration problem the
|
|
// caller can fix, not a server error.
|
|
return {
|
|
ok: false,
|
|
code: 'VALIDATION_ERROR',
|
|
http_status: 400,
|
|
error:
|
|
err instanceof Error ? err.message : 'Organisationsnummer saknas i företagsinställningar',
|
|
}
|
|
}
|
|
|
|
try {
|
|
// Helårsmoms is filed per räkenskapsår (SFL 26 kap 10-11 §§): a broken
|
|
// fiscal year ends in its own month, not December. Same resolution as
|
|
// buildMomsuppgift so the period identifier matches what was filed.
|
|
let fiscalYearEnd: { year: number; month: number } | undefined
|
|
if (input.periodType === 'yearly') {
|
|
const { end } = await resolvePeriodDates(
|
|
supabase, companyId, input.periodType, input.year, input.period,
|
|
)
|
|
fiscalYearEnd = { year: Number(end.slice(0, 4)), month: Number(end.slice(5, 7)) }
|
|
}
|
|
const redovisningsperiod = formatRedovisningsperiod(
|
|
input.periodType, input.year, input.period, fiscalYearEnd,
|
|
)
|
|
|
|
const resolved = await resolveReadAuth(supabase, companyId, {
|
|
requires: 'moms_ombud',
|
|
userId,
|
|
})
|
|
if (!resolved.ok) {
|
|
return {
|
|
ok: false,
|
|
code: 'SKATTEVERKET_NOT_CONNECTED',
|
|
http_status: 401,
|
|
error:
|
|
resolved.reason === 'needs_reconsent'
|
|
? '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.',
|
|
}
|
|
}
|
|
|
|
const fetchView = async (
|
|
view: 'inlamnat' | 'beslutat',
|
|
): Promise<
|
|
| { ok: true; body: unknown }
|
|
| { ok: false; failure: Extract<SkvVatDeclarationStatusResult, { ok: false }> }
|
|
> => {
|
|
const res = await skvRequestWithAuth(
|
|
resolved.auth, 'GET', `/${view}/${redovisare}/${redovisningsperiod}`,
|
|
)
|
|
// Parse the 2xx body BEFORE writing the audit row, so a success status
|
|
// with an unreadable body is recorded as skv_error, not 'ok', and maps
|
|
// to SKATTEVERKET_API_ERROR instead of escaping as an internal 500.
|
|
let body: unknown = null
|
|
let bodyUnparseable = false
|
|
if (res.ok) {
|
|
try {
|
|
body = await res.json()
|
|
} catch {
|
|
bodyUnparseable = true
|
|
}
|
|
}
|
|
// 404 means "nothing on file for the period": a normal answer, not an
|
|
// upstream failure. Same audit convention as the MCP status tool.
|
|
const upstreamOk = (res.ok && !bodyUnparseable) || res.status === 404
|
|
await writeSkatteverketAudit(ctx, {
|
|
endpoint: view,
|
|
agRegistreradId: redovisare,
|
|
redovisningsperiod,
|
|
outcome: upstreamOk ? 'ok' : 'skv_error',
|
|
responseStatus: res.status,
|
|
})
|
|
if (res.status === 404) return { ok: true, body: null }
|
|
if (!res.ok || bodyUnparseable) {
|
|
// The upstream body is logged server-side only. Forwarding it verbatim
|
|
// to API consumers would leak Skatteverket system details; the caller
|
|
// gets the status code and a generic Swedish message.
|
|
const text = bodyUnparseable
|
|
? '<2xx body was not valid JSON>'
|
|
: await res.text().catch(() => '')
|
|
ctx.log.warn('skv declaration-status upstream error', {
|
|
view,
|
|
status: res.status,
|
|
body: text.slice(0, 500),
|
|
})
|
|
return {
|
|
ok: false,
|
|
failure: {
|
|
ok: false,
|
|
code: 'SKATTEVERKET_API_ERROR',
|
|
http_status: 502,
|
|
error: bodyUnparseable
|
|
? 'Skatteverket svarade med ett svar som inte kunde tolkas.'
|
|
: `Skatteverket svarade med ${res.status}.`,
|
|
},
|
|
}
|
|
}
|
|
return { ok: true, body }
|
|
}
|
|
|
|
let submitted: unknown = null
|
|
let decided: unknown = null
|
|
if (state === 'submitted' || state === 'both') {
|
|
const result = await fetchView('inlamnat')
|
|
if (!result.ok) return result.failure
|
|
submitted = result.body
|
|
}
|
|
if (state === 'decided' || state === 'both') {
|
|
const result = await fetchView('beslutat')
|
|
if (!result.ok) return result.failure
|
|
decided = result.body
|
|
}
|
|
|
|
return { ok: true, redovisare, redovisningsperiod, submitted, decided }
|
|
} catch (err) {
|
|
if (err instanceof SkatteverketAuthError) {
|
|
const mapped = skvAuthCodeToStructured(err.code)
|
|
return { ok: false, code: mapped.code, http_status: mapped.httpStatus, error: err.message }
|
|
}
|
|
throw err
|
|
}
|
|
}
|