feat(salary, skatteverket): per-day absence + AGI Frånvarouppgift + skattekonto + hardening (#388)

* feat(salary): per-day absence tracking with calendar UX

Replace aggregated-day absence counts with per-day records so payroll
calculations can correctly enforce Swedish legal rules that depend on
actual dates: karensavdrag once per sjuklöneperiod, återinsjuknande
within 5 calendar days, allmänt högriskskydd cap of 10 karensavdrag per
rolling 12 months, day-8 läkarintyg flag, day-15 transition to
Försäkringskassan.

Adds:
- salary_absence_days table (RLS, dedup unique on employee+date+type)
- /api/salary/employees/[id]/absence CRUD route
- deriveAbsenceLineItems helper that walks per-day records into
  sjuklöneperioder and emits correctly-classified line items, with the
  existing absence-calculator formulas reused for VAB / parental
- Per-employee pay-spec detail page with month-grid AbsenceCalendar
- Calculate route now derives line items from the calendar before
  running the salary engine, replacing the prior sumQuantity model
- Salary run GET surfaces the formatted Skatteverket arbetsgivare ID
  so downstream UI can build extension URLs without a second round-trip
- GET /salary/runs/[id]/employees/[employeeId] for the detail page

Tests: 15 new unit tests covering segment merge, återinsjuknande
within 5 days, högriskskydd cap, FK transition flag, läkarintyg flag,
VAB/parental semesterlönegrundande ceilings.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(skatteverket): harden API client + add NEXT_PUBLIC_SKATTEVERKET_ENABLED feature flag

Three hardening fixes from the prior audit, plus a runtime extension
toggle for phased rollout.

api-client.ts:
- Map 429 to a new SkatteverketAuthError code RATE_LIMITED with a
  Swedish user message. The 4 req/sec local rate limiter normally
  prevents this, but the per-consumer gateway quota can still hit.
- Extend the error union with TOKEN_CORRUPTED for the token-store fix
  below.

token-store.ts:
- Surface decryption failures instead of silently returning null. A
  rotated key or tampered ciphertext used to look like "not connected";
  callers now get TOKEN_CORRUPTED with a clear "anslut igen med BankID"
  message and a structured log line for ops.

Extension dispatcher (app/api/extensions/ext/[...path]/route.ts):
- Per-extension feature flag table. When NEXT_PUBLIC_SKATTEVERKET_ENABLED
  is not exactly "true", the dispatcher returns 503 with code
  EXTENSION_DISABLED, letting ops disable a single integration mid-
  rollout without redeploying or removing it from extensions.config.json.
  UI panels (SkatteverketPanel, AGIPanel) detect the 503 and render an
  empty state.

Tests: 7 api-client cases (401/403/403-Behörighet/429/5xx/200/auth-error
codes) + 2 token-store cases (no-row → null, corrupted → TOKEN_CORRUPTED).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(salary): emit AGI Frånvarouppgift per SKV 4785, add AGIPanel for one-click submission

AGI XML upgrade:
- Emit <gem:Franvarouppgift> top-level blocks for VAB and parental
  leave events sourced from salary_absence_days, per SKV 4785 +
  technical doc. Element order matches the spec example file.
  TILLFALLIG_FORALDRAPENNING for VAB / FORALDRAPENNING for parental,
  with FranvaroTimmarTFP (FK825) or FranvaroTimmarFP (FK827) for
  hours. Stable 1-based specifikationsnummer per (employee, period),
  date-sorted. Skipped entirely for periods before 202501.
- Sick days are NOT emitted (they go to Försäkringskassan).
- FK499 TotalSjuklonekostnad now derived from sick_day2_14.quantity
  × dailyRate × 0.80 instead of Math.abs(amount). The line-item
  amount is the net deduction (lostPay − sjuklon), not the cost,
  so the prior formula understated by a factor of four.

AGI submission UI:
- New AGIPanel mirroring SkatteverketPanel's validate → draft → lock
  → BankID-sign → poll-submitted flow. Detects 503 EXTENSION_DISABLED
  and renders a clear empty state. Replaces the bare "Skicka till
  Skatteverket" button on /salary/runs/[id], keeping the AGI XML
  download as a sibling for archival / manual upload fallback.
- Salary run rows now link to the per-employee detail page added in
  the previous commit.

Tests: 14 new agi-xml cases covering element order, type↔hour-field
mapping, specifikationsnummer ordering, fractional-hour formatting,
range clamping (0.01-24.00), period guard at 202501 boundary,
placement after Blankett blocks, multi-employee date ordering,
required-fields invariant, omission when no events.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(skatteverket): skattekonto integration — read-only saldo + transactions, daily sync, per-row bokför

Adds read-only Skattekonto v2.1 access via the existing BankID OAuth
flow (extends the OAuth scope with `skattekonto`). Daily background
sync pulls saldo + transactions, dedupes on (company_id, dedup_key),
and surfaces the data in a /skattekonto dashboard plus a settings
panel for connection management.

Backend:
- skattekonto-client.ts: GET /skattekonton/{omfragad}/saldo and
  /transaktioner. Felkod 1–5 mapped to Swedish messages via dedicated
  SkatteverketSkattekontoError.
- skattekonto-sync.ts: parallel saldo + transaktioner fetch, UPSERT
  on (company_id, dedup_key) so kommande rows graduate to tidigare
  in place. Dedup key uses transaktionsidentitet when available, else
  sha256 of (date|amount|text). Caches saldo snapshot in extension_data.
  Emits skattekonto.synced / balance.changed (sign flip) /
  transaction.upcoming (first appearance) / connection.expired.
- skattekonto-booking.ts: keyword→counter-account rules with AB/EF
  differentiation (2510 vs 2012 for preliminärskatt; 2731/2710/2650
  for arbetsgivaravgifter/avdragen skatt/moms; 8423/8313 for
  kostnads-/intäktsränta). Creates a draft journal entry against
  BAS 1630, leaves it for the user to review and commit. Throws
  NO_COUNTER_ACCOUNT instead of guessing when no rule matches.
- Daily cron at 0 4 * * * (Swedish 06:00). Double-gated by
  CRON_SECRET and NEXT_PUBLIC_SKATTEVERKET_ENABLED. Per-company
  cooldown of 1 hour, time budget 50s, distinct `expired` status
  for token-exhaustion separate from generic errors.

Database:
- skattekonto_transactions: company-scoped with RLS, unique
  (company_id, dedup_key), indexed on (company_id, date DESC) and
  (company_id, status). journal_entry_id FK with ON DELETE SET NULL
  so a row can be re-bokförd after entry deletion.

Frontend:
- /skattekonto/page.tsx: dashboard with saldo card, transactions list
  (booked + upcoming), per-row "Bokför" action.
- /settings/skatteverket: connection panel showing scope/expiry.
- Extension toggle in SettingsSidebar (gated by ENABLED_EXTENSION_IDS).

Tests: 9 booking-rule cases (counter-account guessing, AB/EF
divergence, no-match throw) + 7 mapper cases (dedup key stability,
sign convention, kommande→tidigare graduation).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix: address PR review findings

Build:
- Fix Next.js build failure: Zod refuses .partial() on a refined schema.
  Replace AbsenceRangeQuerySchema.partial().extend(...) in the absence
  DELETE handler with a fresh z.object that defines its own optional
  fields.

Greptile findings (PR #388):
- skattekonto_transactions UPDATE policy was missing WITH CHECK; without
  it a user could mutate company_id to one they don't belong to. Edit
  the original migration for fresh applies + add a follow-up migration
  that drops/recreates the policy with both clauses (already applied
  to prod via Supabase MCP).
- FK499 TotalSjuklonekostnad now reads sjuklonRate from
  run.calculation_params (snapshot taken at calc time) instead of a
  hardcoded 0.80, so an operator override (e.g. CBA-specific rate) is
  honored. Falls back to 0.80 for older runs without the snapshot.
- Rename NEXT_PUBLIC_SKATTEVERKET_ENABLED → SKATTEVERKET_ENABLED so the
  flag is server-side only. NEXT_PUBLIC_* vars are inlined into the
  client bundle at build time, which would create split-brain (server
  503 vs client still rendering enabled flow) on a flag flip without
  redeploy. UI panels detect 503 by response code, not by reading the
  env directly, so no client-visible change is needed.
- Add pg-real RLS smoke tests for both new tables (salary_absence_days
  and skattekonto_transactions): tenant SELECT isolation, UPDATE WITH
  CHECK enforcement, unique-constraint enforcement, cross-tenant dedup
  key allowed.

Swedish compliance review:
- Document the högriskskydd cap interpretation in
  derive-absence-line-items.ts. We count *sjuklöneperioder* in the
  rolling 12-month window, matching the law's plain reading
  ("från och med den 11:e sjukperioden ... görs inget karensavdrag").
  An alternative reading counts only periods that actually had karens
  deducted; that requires persisting per-period karens-deduction state,
  which gnubok doesn't yet do. The period-count reading can over-
  suppress, never under-suppress, so it's the safer default.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(test): inline skattekonto fixtures so core-only CI runs without dev_docs

dev_docs/ is gitignored, so the skattekonto-mappers test failed in CI
when it tried to readFileSync from dev_docs/skattekonto(2.1.0)/examples/.
Inline the saldoResponse + transaktionerResponse fixtures verbatim
from the spec; the test still verifies our mappers + dedup-key logic
against the same shape.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Jakob Wennberg
2026-05-04 19:01:21 +02:00
committed by GitHub
co-authored by Claude Opus 4.7
parent bb855d2ddc
commit f3fd4c0822
39 changed files with 5226 additions and 161 deletions
@@ -0,0 +1,210 @@
'use client'
import { use, useEffect, useMemo, useState } from 'react'
import Link from 'next/link'
import { ArrowLeft, Loader2 } from 'lucide-react'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { AbsenceCalendar } from '@/components/salary/AbsenceCalendar'
import { formatCurrency } from '@/lib/utils'
import type { SalaryRun, SalaryRunEmployee, SalaryLineItem, Employee } from '@/types'
interface DetailResponse {
run: SalaryRun
runEmployee: SalaryRunEmployee & { employee: Employee; line_items: SalaryLineItem[] }
}
export default function SalaryRunEmployeeDetailPage({
params,
}: {
params: Promise<{ id: string; employeeId: string }>
}) {
const { id: runId, employeeId } = use(params)
const [data, setData] = useState<DetailResponse | null>(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const load = async () => {
setLoading(true)
setError(null)
try {
const [runRes, sreRes] = await Promise.all([
fetch(`/api/salary/runs/${runId}`),
fetch(`/api/salary/runs/${runId}/employees/${employeeId}`),
])
const runJson = await runRes.json()
const sreJson = await sreRes.json()
if (!runRes.ok) throw new Error(runJson.error || 'Kunde inte ladda lönekörning')
if (!sreRes.ok) throw new Error(sreJson.error || 'Kunde inte ladda anställd')
setData({ run: runJson.data, runEmployee: sreJson.data })
} catch (e) {
setError(e instanceof Error ? e.message : 'Okänt fel')
} finally {
setLoading(false)
}
}
useEffect(() => {
load()
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [runId, employeeId])
const periodStart = useMemo(() => {
if (!data) return ''
const y = data.run.period_year
const m = data.run.period_month
return `${y}-${String(m).padStart(2, '0')}-01`
}, [data])
const periodEnd = useMemo(() => {
if (!data) return ''
const y = data.run.period_year
const m = data.run.period_month
const last = new Date(Date.UTC(y, m, 0)).getUTCDate()
return `${y}-${String(m).padStart(2, '0')}-${String(last).padStart(2, '0')}`
}, [data])
if (loading) {
return (
<div className="flex items-center justify-center py-12 text-muted-foreground">
<Loader2 className="mr-2 h-4 w-4 animate-spin" /> Laddar...
</div>
)
}
if (error || !data) {
return (
<div className="space-y-3">
<Link
href={`/salary/runs/${runId}`}
className="inline-flex items-center text-sm text-muted-foreground hover:underline"
>
<ArrowLeft className="mr-1 h-3.5 w-3.5" /> Tillbaka till lönekörning
</Link>
<div className="rounded-md bg-destructive/10 p-3 text-sm text-destructive">
{error ?? 'Kunde inte ladda anställd'}
</div>
</div>
)
}
const { run, runEmployee } = data
const employee = runEmployee.employee
const lineItems = runEmployee.line_items ?? []
const periodLabel = `${run.period_year}-${String(run.period_month).padStart(2, '0')}`
const readOnly = run.status !== 'draft' && run.status !== 'review'
return (
<div className="space-y-6">
{/* Header */}
<div className="space-y-3">
<Link
href={`/salary/runs/${runId}`}
className="inline-flex items-center text-sm text-muted-foreground hover:underline"
>
<ArrowLeft className="mr-1 h-3.5 w-3.5" /> Tillbaka till lönekörning
</Link>
<div className="flex flex-wrap items-baseline justify-between gap-2">
<div>
<h1 className="font-serif text-2xl font-medium tracking-tight">
{employee.first_name} {employee.last_name}
</h1>
<p className="text-sm text-muted-foreground tabular-nums">
{employee.personnummer} · Lönespecifikation {periodLabel}
</p>
</div>
<Button variant="outline" size="sm" onClick={load}>
Uppdatera
</Button>
</div>
</div>
{/* Summary */}
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
<SummaryCard label="Brutto" value={runEmployee.gross_salary} />
<SummaryCard label="Skatt" value={runEmployee.tax_withheld} />
<SummaryCard label="Netto" value={runEmployee.net_salary} accent />
<SummaryCard label="Avgifter" value={runEmployee.avgifter_amount} />
</div>
{/* Absence calendar */}
<Card>
<CardHeader>
<CardTitle className="text-base">Frånvaro</CardTitle>
<p className="text-xs text-muted-foreground">
Markera sjukdom, VAB, föräldraledighet och annan frånvaro per dag.
Karensavdrag, sjuklön och AGI-rapportering räknas ut automatiskt.
</p>
</CardHeader>
<CardContent>
<AbsenceCalendar
employeeId={employee.id}
periodStart={periodStart}
periodEnd={periodEnd}
salaryRunEmployeeId={runEmployee.id}
readOnly={readOnly}
onChange={load}
/>
<div className="mt-3 grid grid-cols-3 gap-2 text-xs">
<AbsenceCount label="Sjukdagar" days={runEmployee.sick_days} />
<AbsenceCount label="VAB-dagar" days={runEmployee.vab_days} />
<AbsenceCount label="Föräldraledig" days={runEmployee.parental_days} />
</div>
</CardContent>
</Card>
{/* Line items */}
<Card>
<CardHeader>
<CardTitle className="text-base">Lönerader ({lineItems.length})</CardTitle>
</CardHeader>
<CardContent className="p-0">
{lineItems.length === 0 ? (
<p className="px-4 py-6 text-center text-sm text-muted-foreground">
Inga lönerader. Kör beräkning på lönekörningen för att skapa standardrader.
</p>
) : (
<table className="w-full">
<thead>
<tr className="border-b text-left text-xs text-muted-foreground">
<th className="px-4 py-2 font-medium">Typ</th>
<th className="px-4 py-2 font-medium">Beskrivning</th>
<th className="px-4 py-2 font-medium text-right">Antal</th>
<th className="px-4 py-2 font-medium text-right">Belopp</th>
</tr>
</thead>
<tbody>
{lineItems.map(li => (
<tr key={li.id} className="border-b last:border-0">
<td className="px-4 py-2 text-xs text-muted-foreground">{li.item_type}</td>
<td className="px-4 py-2 text-sm">{li.description}</td>
<td className="px-4 py-2 text-sm text-right tabular-nums">{li.quantity ?? '—'}</td>
<td className="px-4 py-2 text-sm text-right tabular-nums">{formatCurrency(li.amount)}</td>
</tr>
))}
</tbody>
</table>
)}
</CardContent>
</Card>
</div>
)
}
function SummaryCard({ label, value, accent }: { label: string; value: number; accent?: boolean }) {
return (
<div className={`rounded-md border bg-card p-3 ${accent ? 'ring-1 ring-primary/40' : ''}`}>
<div className="text-xs text-muted-foreground">{label}</div>
<div className="mt-0.5 text-lg font-medium tabular-nums">{formatCurrency(value)}</div>
</div>
)
}
function AbsenceCount({ label, days }: { label: string; days: number }) {
return (
<div className="rounded-md border bg-muted/30 px-2.5 py-1.5">
<div className="text-[11px] text-muted-foreground">{label}</div>
<div className="text-sm font-medium tabular-nums">{days} dagar</div>
</div>
)
}
+58 -114
View File
@@ -7,14 +7,17 @@ import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import {
ArrowLeft, Plus, Calculator, Eye, Check, CreditCard, BookOpen,
ArrowLeftCircle, Loader2, Download, Send, CheckCircle2,
ArrowLeft, Calculator, Eye, Check, CreditCard, BookOpen,
ArrowLeftCircle, Loader2, Download,
} from 'lucide-react'
import { useToast } from '@/components/ui/use-toast'
import { useCanWrite } from '@/lib/hooks/use-can-write'
import { formatCurrency } from '@/lib/utils'
import { getErrorMessage } from '@/lib/errors/get-error-message'
import type { SalaryRun, SalaryRunEmployee, Employee, CreateJournalEntryLineInput } from '@/types'
import { AGIPanel } from '@/components/salary/AGIPanel'
type SalaryRunWithArbetsgivare = SalaryRun & { arbetsgivare?: string | null }
const STATUS_LABELS: Record<string, string> = {
draft: 'Utkast',
@@ -172,45 +175,6 @@ export default function SalaryRunDetailPage({ params }: { params: Promise<{ id:
setActionLoading(null)
}
async function handleSubmitAgi() {
setActionLoading('agi-submit')
// Generate AGI XML first if it hasn't been generated yet.
if (!run?.agi_generated_at) {
const xmlRes = await fetch(`/api/salary/runs/${id}/agi/xml`)
if (!xmlRes.ok) {
const result = await xmlRes.json().catch(() => ({ error: 'Kunde inte generera AGI-fil' }))
toast({
title: 'AGI kunde inte genereras',
description: getErrorMessage(result, { context: 'salary', statusCode: xmlRes.status }),
variant: 'destructive',
})
setActionLoading(null)
return
}
}
const res = await fetch(`/api/salary/runs/${id}/agi/submit`, { method: 'POST' })
const payload = await res.json().catch(() => ({}))
if (res.ok) {
await loadRun()
toast({
title: 'AGI skickad till Skatteverket',
description:
(payload?.data?.message as string | undefined) ??
'Signera med BankID hos Skatteverket för att slutföra inlämningen.',
})
} else {
toast({
title: 'Kunde inte skicka till Skatteverket',
description: getErrorMessage(payload, { context: 'salary', statusCode: res.status }),
variant: 'destructive',
})
}
setActionLoading(null)
}
if (loading) {
return (
<div className="space-y-6">
@@ -310,20 +274,34 @@ export default function SalaryRunDetailPage({ params }: { params: Promise<{ id:
</tr>
</thead>
<tbody>
{employees.map(sre => (
<tr key={sre.id} className="border-b last:border-0">
<td className="px-4 py-3 text-sm font-medium">
{(sre as SalaryRunEmployee & { employee?: { first_name: string; last_name: string; personnummer: string } }).employee
? `${(sre as SalaryRunEmployee & { employee: { first_name: string; last_name: string } }).employee.first_name} ${(sre as SalaryRunEmployee & { employee: { first_name: string; last_name: string } }).employee.last_name}`
: `Anställd ${sre.employee_id.slice(0, 8)}...`}
</td>
<td className="px-4 py-3 text-sm text-right tabular-nums">{formatCurrency(sre.gross_salary)}</td>
<td className="px-4 py-3 text-sm text-right tabular-nums">{formatCurrency(sre.tax_withheld)}</td>
<td className="px-4 py-3 text-sm text-right tabular-nums">{formatCurrency(sre.net_salary)}</td>
<td className="px-4 py-3 text-sm text-right tabular-nums">{formatCurrency(sre.avgifter_amount)}</td>
<td className="px-4 py-3 text-sm text-right tabular-nums">{formatCurrency(sre.vacation_accrual)}</td>
</tr>
))}
{employees.map(sre => {
const employee = (sre as SalaryRunEmployee & { employee?: { first_name: string; last_name: string; personnummer: string } }).employee
const name = employee
? `${employee.first_name} ${employee.last_name}`
: `Anställd ${sre.employee_id.slice(0, 8)}...`
return (
<tr
key={sre.id}
className="cursor-pointer border-b transition-colors last:border-0 hover:bg-accent/40"
onClick={() => router.push(`/salary/runs/${id}/employees/${sre.employee_id}`)}
>
<td className="px-4 py-3 text-sm font-medium">
<Link
href={`/salary/runs/${id}/employees/${sre.employee_id}`}
className="hover:underline"
onClick={(e) => e.stopPropagation()}
>
{name}
</Link>
</td>
<td className="px-4 py-3 text-sm text-right tabular-nums">{formatCurrency(sre.gross_salary)}</td>
<td className="px-4 py-3 text-sm text-right tabular-nums">{formatCurrency(sre.tax_withheld)}</td>
<td className="px-4 py-3 text-sm text-right tabular-nums">{formatCurrency(sre.net_salary)}</td>
<td className="px-4 py-3 text-sm text-right tabular-nums">{formatCurrency(sre.avgifter_amount)}</td>
<td className="px-4 py-3 text-sm text-right tabular-nums">{formatCurrency(sre.vacation_accrual)}</td>
</tr>
)
})}
</tbody>
</table>
)}
@@ -403,68 +381,34 @@ export default function SalaryRunDetailPage({ params }: { params: Promise<{ id:
{/* AGI (Arbetsgivardeklaration) — available once the run is booked */}
{run.status === 'booked' && (
<Card>
<CardHeader>
<CardTitle className="text-base">Arbetsgivardeklaration (AGI)</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-1.5 text-sm">
<div className="flex items-center gap-2">
{run.agi_generated_at ? (
<>
<CheckCircle2 className="h-4 w-4 text-emerald-600" />
<span className="text-muted-foreground">
AGI-fil genererad {new Date(run.agi_generated_at).toLocaleString('sv-SE')}
</span>
</>
<div className="space-y-3">
<AGIPanel
salaryRunId={id}
arbetsgivare={(run as SalaryRunWithArbetsgivare).arbetsgivare ?? ''}
period={`${run.period_year}${String(run.period_month).padStart(2, '0')}`}
agiGeneratedAt={run.agi_generated_at}
agiSubmittedAt={run.agi_submitted_at}
readOnly={!canWrite}
onChange={loadRun}
/>
{canWrite && (
<div className="flex justify-end">
<Button
variant="outline"
size="sm"
onClick={handleDownloadAgi}
disabled={!!actionLoading}
>
{actionLoading === 'agi-download' ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<span className="text-muted-foreground">AGI-fil har inte genererats ännu.</span>
<Download className="mr-2 h-4 w-4" />
)}
</div>
<div className="flex items-center gap-2">
{run.agi_submitted_at ? (
<>
<CheckCircle2 className="h-4 w-4 text-emerald-600" />
<span className="text-muted-foreground">
Skickad till Skatteverket {new Date(run.agi_submitted_at).toLocaleString('sv-SE')}
</span>
</>
) : (
<span className="text-muted-foreground">
Inte skickad till Skatteverket ännu. Deadline: 12:e i månaden efter utbetalning.
</span>
)}
</div>
Ladda ner AGI-fil (XML)
</Button>
</div>
{canWrite && (
<div className="flex flex-wrap gap-3">
<Button
variant="outline"
onClick={handleDownloadAgi}
disabled={!!actionLoading}
>
{actionLoading === 'agi-download' ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<Download className="mr-2 h-4 w-4" />
)}
Ladda ner AGI-fil
</Button>
<Button
onClick={handleSubmitAgi}
disabled={!!actionLoading || !!run.agi_submitted_at}
>
{actionLoading === 'agi-submit' ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<Send className="mr-2 h-4 w-4" />
)}
Skicka till Skatteverket
</Button>
</div>
)}
</CardContent>
</Card>
)}
</div>
)}
{/* Actions */}
@@ -0,0 +1,40 @@
'use client'
import { useEffect } from 'react'
import { useSearchParams, useRouter } from 'next/navigation'
import { useToast } from '@/components/ui/use-toast'
import { SkatteverketConnectPanel } from '@/components/settings/SkatteverketConnectPanel'
export default function SkatteverketSettingsPage() {
const searchParams = useSearchParams()
const router = useRouter()
const { toast } = useToast()
useEffect(() => {
const connected = searchParams.get('skv_connected')
const error = searchParams.get('skv_error')
if (connected === 'true') {
toast({
title: 'Skatteverket anslutet',
description: 'Du kan nu skicka deklarationer och hämta skattekonto-saldot.',
})
router.replace('/settings/skatteverket')
} else if (error) {
let msg: string
try { msg = decodeURIComponent(error) } catch { msg = error }
toast({
title: 'Anslutning misslyckades',
description: msg,
variant: 'destructive',
})
router.replace('/settings/skatteverket')
}
}, [searchParams, router, toast])
return (
<div className="space-y-6">
<SkatteverketConnectPanel />
</div>
)
}
+433
View File
@@ -0,0 +1,433 @@
'use client'
import { useCallback, useEffect, useState } from 'react'
import Link from 'next/link'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
import { useToast } from '@/components/ui/use-toast'
import { formatCurrency } from '@/lib/utils'
import {
Copy,
ExternalLink,
FileCheck,
Landmark,
RefreshCw,
} from 'lucide-react'
import type {
SkatteverketSaldoResponse,
StoredSkattekontoTransaction,
} from '@/extensions/general/skatteverket/types'
interface SaldoEnvelope {
data: SkatteverketSaldoResponse | null
fetchedAt: string | null
lastSyncedAt: string | null
}
interface TransaktionerEnvelope {
data: {
booked: StoredSkattekontoTransaction[]
upcoming: StoredSkattekontoTransaction[]
}
}
export default function SkattekontoPage() {
const { toast } = useToast()
const [saldo, setSaldo] = useState<SaldoEnvelope | null>(null)
const [tx, setTx] = useState<TransaktionerEnvelope['data'] | null>(null)
const [loading, setLoading] = useState(true)
const [syncing, setSyncing] = useState(false)
const [bookingId, setBookingId] = useState<string | null>(null)
const [notConnected, setNotConnected] = useState(false)
const reload = useCallback(async () => {
setLoading(true)
try {
const [saldoRes, txRes] = await Promise.all([
fetch('/api/extensions/ext/skatteverket/skattekonto/saldo'),
fetch('/api/extensions/ext/skatteverket/skattekonto/transaktioner'),
])
if (saldoRes.status === 401) {
setNotConnected(true)
return
}
const saldoJson = (await saldoRes.json()) as SaldoEnvelope
setSaldo(saldoJson)
if (txRes.ok) {
const txJson = (await txRes.json()) as TransaktionerEnvelope
setTx(txJson.data)
}
} finally {
setLoading(false)
}
}, [])
useEffect(() => {
void reload()
}, [reload])
async function syncNow() {
setSyncing(true)
try {
const res = await fetch('/api/extensions/ext/skatteverket/skattekonto/sync', {
method: 'POST',
})
const json = await res.json()
if (!res.ok) {
if (res.status === 401) {
setNotConnected(true)
return
}
throw new Error(json.error || 'Synk misslyckades')
}
toast({
title: 'Skattekonto synkroniserat',
description: `${json.data.booked} bokförda, ${json.data.upcoming} kommande`,
})
await reload()
} catch (err) {
toast({
title: 'Synk misslyckades',
description: err instanceof Error ? err.message : undefined,
variant: 'destructive',
})
} finally {
setSyncing(false)
}
}
async function bokfor(id: string) {
setBookingId(id)
try {
const res = await fetch(
`/api/extensions/ext/skatteverket/skattekonto/transaktioner/${id}/bokfor`,
{ method: 'POST' },
)
const json = await res.json()
if (!res.ok) {
throw new Error(json.error || 'Bokföring misslyckades')
}
toast({
title: 'Utkast skapat',
description: 'Granska och bokför verifikatet i Bokföring.',
})
// Take the user to the draft so they can review.
window.location.href = `/bookkeeping/${json.data.entry.id}`
} catch (err) {
toast({
title: 'Kunde inte bokföra',
description: err instanceof Error ? err.message : undefined,
variant: 'destructive',
})
} finally {
setBookingId(null)
}
}
function copyOcr(ocr: string) {
navigator.clipboard
.writeText(ocr)
.then(() => toast({ title: 'OCR kopierat' }))
.catch(() => {})
}
if (notConnected) {
return (
<div className="space-y-6">
<PageHeading />
<Card>
<CardContent className="flex flex-col items-center py-12 text-center">
<Landmark className="mb-4 h-10 w-10 text-muted-foreground/40" />
<p className="mb-1 font-medium">Skatteverket är inte anslutet</p>
<p className="mb-4 max-w-md text-sm text-muted-foreground">
För att se saldo och transaktioner på skattekontot behöver du
ansluta med BankID i inställningarna.
</p>
<Button asChild>
<Link href="/settings/skatteverket">
<ExternalLink className="mr-2 h-4 w-4" />
Anslut Skatteverket
</Link>
</Button>
</CardContent>
</Card>
</div>
)
}
return (
<div className="space-y-6">
<PageHeading
right={
<Button onClick={syncNow} disabled={syncing}>
<RefreshCw className={`mr-2 h-4 w-4 ${syncing ? 'animate-spin' : ''}`} />
{syncing ? 'Synkroniserar…' : 'Synkronisera nu'}
</Button>
}
/>
<BalanceHero saldo={saldo} loading={loading} onCopyOcr={copyOcr} />
<Card>
<CardHeader>
<CardTitle>Transaktioner</CardTitle>
</CardHeader>
<CardContent>
<Tabs defaultValue="booked">
<TabsList>
<TabsTrigger value="booked">
Bokförda {tx?.booked ? `(${tx.booked.length})` : ''}
</TabsTrigger>
<TabsTrigger value="upcoming">
Kommande {tx?.upcoming ? `(${tx.upcoming.length})` : ''}
</TabsTrigger>
</TabsList>
<TabsContent value="booked" className="mt-4">
<TransactionTable
rows={tx?.booked ?? []}
onBokfor={bokfor}
bookingId={bookingId}
emptyText="Inga bokförda transaktioner än."
/>
</TabsContent>
<TabsContent value="upcoming" className="mt-4">
<TransactionTable
rows={tx?.upcoming ?? []}
onBokfor={bokfor}
bookingId={bookingId}
emptyText="Inga kommande transaktioner."
showForfallodatum
/>
</TabsContent>
</Tabs>
</CardContent>
</Card>
</div>
)
}
function PageHeading({ right }: { right?: React.ReactNode }) {
return (
<div className="flex items-end justify-between gap-4">
<div>
<h1 className="font-serif text-3xl">Skattekonto</h1>
<p className="text-sm text-muted-foreground">
Saldo och transaktioner från Skatteverket
</p>
</div>
{right}
</div>
)
}
function BalanceHero({
saldo,
loading,
onCopyOcr,
}: {
saldo: SaldoEnvelope | null
loading: boolean
onCopyOcr: (ocr: string) => void
}) {
if (loading && !saldo?.data) {
return (
<Card>
<CardContent className="py-12 text-sm text-muted-foreground">
Hämtar saldo…
</CardContent>
</Card>
)
}
if (!saldo?.data) {
return (
<Card>
<CardContent className="py-12 text-center text-sm text-muted-foreground">
Inget saldo hämtat ännu — klicka på &quot;Synkronisera nu&quot;.
</CardContent>
</Card>
)
}
const { data } = saldo
const skvNegative = data.saldoSkatteverket < 0
const kfmNegative = data.saldoKronofogden < 0
return (
<Card>
<CardContent className="space-y-6 pt-6">
<div className="grid grid-cols-1 gap-6 md:grid-cols-2">
<div>
<p className="text-xs uppercase tracking-wide text-muted-foreground">
Skatteverket
</p>
<p
className={`font-serif text-4xl tabular-nums ${
skvNegative ? 'text-destructive' : 'text-foreground'
}`}
>
{formatCurrency(data.saldoSkatteverket)}
</p>
{data.rantaSkatteverket !== 0 && (
<p className="mt-1 text-xs text-muted-foreground tabular-nums">
Preliminär ränta: {formatCurrency(data.rantaSkatteverket)}
</p>
)}
</div>
<div>
<p className="text-xs uppercase tracking-wide text-muted-foreground">
Kronofogden
</p>
<p
className={`font-serif text-4xl tabular-nums ${
kfmNegative ? 'text-destructive' : 'text-foreground'
}`}
>
{formatCurrency(data.saldoKronofogden)}
</p>
{data.rantaKronofogden !== 0 && (
<p className="mt-1 text-xs text-muted-foreground tabular-nums">
Preliminär ränta: {formatCurrency(data.rantaKronofogden)}
</p>
)}
</div>
</div>
<div className="grid grid-cols-1 gap-4 border-t pt-4 text-sm sm:grid-cols-3">
<div>
<p className="text-xs uppercase tracking-wide text-muted-foreground">
OCR
</p>
<p className="flex items-center gap-2 font-medium tabular-nums">
{data.ocrNummer}
<button
onClick={() => onCopyOcr(data.ocrNummer)}
className="text-muted-foreground hover:text-foreground"
aria-label="Kopiera OCR"
>
<Copy className="h-3.5 w-3.5" />
</button>
</p>
</div>
<div>
<p className="text-xs uppercase tracking-wide text-muted-foreground">
Nästa avstämning
</p>
<p className="font-medium tabular-nums">{data.nastaAvstamningsdatum}</p>
</div>
<div>
<p className="text-xs uppercase tracking-wide text-muted-foreground">
Senast uppdaterad
</p>
<p className="font-medium tabular-nums">
{new Date(data.senastUppdaterad).toLocaleString('sv-SE')}
</p>
</div>
</div>
{data.informationstext.length > 0 && (
<div className="rounded-md border bg-muted/30 p-3">
<p className="mb-1 text-xs font-medium uppercase tracking-wide">
Information från Skatteverket
</p>
<ul className="list-disc space-y-1 pl-5 text-sm">
{data.informationstext.map((t, i) => (
<li key={i}>{t}</li>
))}
</ul>
</div>
)}
</CardContent>
</Card>
)
}
function TransactionTable({
rows,
onBokfor,
bookingId,
emptyText,
showForfallodatum = false,
}: {
rows: StoredSkattekontoTransaction[]
onBokfor: (id: string) => void
bookingId: string | null
emptyText: string
showForfallodatum?: boolean
}) {
if (rows.length === 0) {
return <p className="py-8 text-center text-sm text-muted-foreground">{emptyText}</p>
}
return (
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b text-left text-xs uppercase tracking-wide text-muted-foreground">
<th className="py-2 pr-3">Datum</th>
{showForfallodatum && <th className="py-2 pr-3">Förfallodatum</th>}
<th className="py-2 pr-3">Beskrivning</th>
<th className="py-2 pr-3 text-right">Belopp</th>
<th className="py-2 pr-3">Status</th>
<th className="py-2"></th>
</tr>
</thead>
<tbody>
{rows.map(row => {
const negative = Number(row.belopp_skatteverket) < 0
const isBooked = !!row.journal_entry_id
return (
<tr key={row.id} className="border-b last:border-0">
<td className="py-2 pr-3 tabular-nums">{row.transaktionsdatum}</td>
{showForfallodatum && (
<td className="py-2 pr-3 tabular-nums">{row.forfallodatum ?? '–'}</td>
)}
<td className="py-2 pr-3">{row.transaktionstext}</td>
<td
className={`py-2 pr-3 text-right tabular-nums ${
negative ? 'text-destructive' : ''
}`}
>
{formatCurrency(Number(row.belopp_skatteverket))}
</td>
<td className="py-2 pr-3">
{isBooked ? (
<Badge variant="secondary" className="gap-1">
<FileCheck className="h-3 w-3" />
Bokförd
</Badge>
) : (
<Badge variant="outline">Ej bokförd</Badge>
)}
</td>
<td className="py-2 text-right">
{isBooked ? (
<Button asChild variant="ghost" size="sm">
<Link href={`/bookkeeping/${row.journal_entry_id}`}>
Visa verifikat
</Link>
</Button>
) : (
<Button
variant="outline"
size="sm"
onClick={() => onBokfor(row.id)}
disabled={bookingId === row.id}
>
{bookingId === row.id ? 'Bokför…' : 'Bokför'}
</Button>
)}
</td>
</tr>
)
})}
</tbody>
</table>
</div>
)
}
+33
View File
@@ -11,6 +11,27 @@ ensureInitialized()
// Heavy extension routes (SIE import, migration) need up to 5 minutes
export const maxDuration = 300
/**
* Per-extension runtime feature flags. Lets ops toggle an integration off
* without redeploying or removing it from extensions.config.json — useful
* for phased rollouts (dev tenants → design partners → general).
*
* The flag is checked on every request. If the env var is not exactly the
* string "true", the dispatcher returns 503 with `code: 'EXTENSION_DISABLED'`.
*
* Server-side env vars only — no NEXT_PUBLIC_ prefix. Next.js inlines
* NEXT_PUBLIC_* into the client bundle at build time, so a flip on Vercel
* without a redeploy would create split-brain (server returns 503,
* client still renders the enabled flow). UI panels detect the 503 by
* response code, not by reading the flag directly.
*/
const EXTENSION_FEATURE_FLAGS: Record<string, { envVar: string; disabledMessage: string }> = {
skatteverket: {
envVar: 'SKATTEVERKET_ENABLED',
disabledMessage: 'Skatteverket-integrationen är inte aktiverad i denna miljö.',
},
}
/**
* Match a request path against a route pattern.
* Supports :param wildcards (e.g., /:id/confirm).
@@ -70,6 +91,18 @@ async function handleRequest(
return NextResponse.json({ error: 'Extension not found' }, { status: 404 })
}
// Per-extension feature flags. Lets us toggle a single integration off
// mid-rollout without redeploying or removing it from extensions.config.json.
// The frontend (SkatteverketPanel, AGIPanel) inspects the 503 + code to
// render an "extension disabled" empty state.
const flag = EXTENSION_FEATURE_FLAGS[extensionId]
if (flag && process.env[flag.envVar] !== 'true') {
return NextResponse.json(
{ error: flag.disabledMessage, code: 'EXTENSION_DISABLED' },
{ status: 503 },
)
}
// Match route BEFORE auth so we can check skipAuth (e.g. OAuth callbacks)
let matchedRoute: ApiRouteDefinition | null = null
let extractedParams: Record<string, string> = {}
@@ -0,0 +1,166 @@
import { createClient } from '@supabase/supabase-js'
import { NextResponse } from 'next/server'
import { ensureInitialized } from '@/lib/init'
import { verifyCronSecret } from '@/lib/auth/cron'
import { createExtensionContext } from '@/lib/extensions/context-factory'
import { syncSkattekonto, SKATTEKONTO_LAST_SYNCED_AT_KEY } from '@/extensions/general/skatteverket/lib/skattekonto-sync'
import { SkatteverketAuthError } from '@/extensions/general/skatteverket/lib/api-client'
import { SkatteverketSkattekontoError } from '@/extensions/general/skatteverket/lib/skattekonto-client'
ensureInitialized()
export const maxDuration = 60
/**
* GET /api/extensions/skatteverket/skattekonto/sync/cron
*
* Daily skattekonto sync (cron 0 4 * * * — 04:00 UTC, 06:00 Swedish time).
* Pulls saldo + transactions for every company that has a connected
* Skatteverket token, and persists the results to skattekonto_transactions.
*
* Skips a company if it was synced within the last hour (cooldown),
* to keep manual + cron triggers from racing each other.
*
* Time budget: 50s (Vercel default 60s function timeout, 10s margin).
*
* Per-company errors are logged but do not abort the run — one expired
* token shouldn't block 49 other working syncs.
*/
export async function GET(request: Request) {
const authError = verifyCronSecret(request)
if (authError) return authError
// Respect the runtime extension toggle. When the integration is disabled
// the cron should no-op rather than spam Skatteverket with stale tokens.
if (process.env.SKATTEVERKET_ENABLED !== 'true') {
return NextResponse.json({ message: 'Skatteverket extension disabled', processed: 0 })
}
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL
const supabaseServiceKey = process.env.SUPABASE_SERVICE_ROLE_KEY
if (!supabaseUrl || !supabaseServiceKey) {
return NextResponse.json({ error: 'Missing Supabase configuration' }, { status: 500 })
}
const supabase = createClient(supabaseUrl, supabaseServiceKey)
// Find all companies with a connected token. The token row is keyed by
// user_id but carries company_id (added in the multi-tenant refactor).
const { data: tokens, error: tokensError } = await supabase
.from('skatteverket_tokens')
.select('user_id, company_id, expires_at, refresh_count')
.order('expires_at', { ascending: true })
.limit(50)
if (tokensError) {
console.error('[skattekonto-sync-cron] Failed to fetch tokens', {
message: tokensError.message,
code: tokensError.code,
})
return NextResponse.json({ error: 'Failed to fetch tokens' }, { status: 500 })
}
if (!tokens || tokens.length === 0) {
return NextResponse.json({ message: 'No connected tokens', processed: 0 })
}
const startTime = Date.now()
const TIME_BUDGET_MS = 50_000
const SYNC_COOLDOWN_MS = 60 * 60 * 1000 // 1 hour
type Result = {
userId: string
companyId: string
status: 'synced' | 'skipped_cooldown' | 'expired' | 'error'
booked?: number
upcoming?: number
error?: string
}
const results: Result[] = []
for (const token of tokens) {
if (Date.now() - startTime > TIME_BUDGET_MS) {
console.log(`[skattekonto-sync-cron] Time budget reached after ${results.length} tokens`)
break
}
const userId = token.user_id as string
const companyId = token.company_id as string | null
if (!companyId) {
// Pre-multi-tenant tokens may lack company_id. Skip — cannot scope.
results.push({ userId, companyId: '(missing)', status: 'error', error: 'No company_id on token' })
continue
}
try {
// Cooldown: skip if synced within the last hour.
const { data: lastSyncRow } = await supabase
.from('extension_data')
.select('value, updated_at')
.eq('company_id', companyId)
.eq('extension_id', 'skatteverket')
.eq('key', SKATTEKONTO_LAST_SYNCED_AT_KEY)
.maybeSingle()
const lastSyncedAt = lastSyncRow?.value as string | undefined
if (lastSyncedAt) {
const elapsed = Date.now() - new Date(lastSyncedAt).getTime()
if (elapsed < SYNC_COOLDOWN_MS) {
results.push({ userId, companyId, status: 'skipped_cooldown' })
continue
}
}
const ctx = createExtensionContext(supabase, userId, companyId, 'skatteverket')
const syncResult = await syncSkattekonto(ctx)
results.push({
userId,
companyId,
status: 'synced',
booked: syncResult.booked,
upcoming: syncResult.upcoming,
})
} catch (err) {
const message = err instanceof Error ? err.message : 'Unknown error'
// Expired token / refresh exhausted is a known outcome — surface it
// distinctly so ops can dashboard "X companies need to reconnect".
if (
err instanceof SkatteverketAuthError &&
(err.code === 'REFRESH_EXHAUSTED' || err.code === 'SESSION_EXPIRED' || err.code === 'TOKEN_CORRUPTED')
) {
results.push({ userId, companyId, status: 'expired', error: err.code })
continue
}
const felkod = err instanceof SkatteverketSkattekontoError ? err.felkod : null
console.error('[skattekonto-sync-cron] Sync failed', {
userId,
companyId,
message,
felkod,
})
results.push({ userId, companyId, status: 'error', error: message })
}
}
const synced = results.filter(r => r.status === 'synced').length
const skipped = results.filter(r => r.status === 'skipped_cooldown').length
const expired = results.filter(r => r.status === 'expired').length
const errors = results.filter(r => r.status === 'error').length
console.log(
`[skattekonto-sync-cron] Processed ${results.length}: ${synced} synced, ${skipped} cooldown, ${expired} expired, ${errors} errors`,
)
return NextResponse.json({
processed: results.length,
synced,
skipped,
expired,
errors,
results,
})
}
@@ -0,0 +1,191 @@
import { z } from 'zod'
import { createClient } from '@/lib/supabase/server'
import { NextResponse } from 'next/server'
import { ensureInitialized } from '@/lib/init'
import { validateBody, validateQuery } from '@/lib/api/validate'
import {
UpsertAbsenceDaySchema,
AbsenceRangeQuerySchema,
AbsenceTypeSchema,
} from '@/lib/api/schemas'
import { requireCompanyId } from '@/lib/company/context'
import { requireWritePermission } from '@/lib/auth/require-write'
const isoDate = z.string().regex(/^\d{4}-\d{2}-\d{2}$/)
ensureInitialized()
async function loadEmployee(
supabase: Awaited<ReturnType<typeof createClient>>,
employeeId: string,
companyId: string,
) {
const { data } = await supabase
.from('employees')
.select('id')
.eq('id', employeeId)
.eq('company_id', companyId)
.maybeSingle()
return data
}
export async function GET(
request: Request,
{ params }: { params: Promise<{ id: string }> },
) {
const { id: employeeId } = await params
const supabase = await createClient()
const { data: { user } } = await supabase.auth.getUser()
if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
const companyId = await requireCompanyId(supabase, user.id)
const employee = await loadEmployee(supabase, employeeId, companyId)
if (!employee) {
return NextResponse.json({ error: 'Anställd hittades inte' }, { status: 404 })
}
const query = validateQuery(request, AbsenceRangeQuerySchema)
if (!query.success) return query.response
const { data, error } = await supabase
.from('salary_absence_days')
.select('id, absence_date, absence_type, hours, notes, salary_run_employee_id, created_at, updated_at')
.eq('company_id', companyId)
.eq('employee_id', employeeId)
.gte('absence_date', query.data.from)
.lte('absence_date', query.data.to)
.order('absence_date', { ascending: true })
if (error) {
return NextResponse.json({ error: error.message }, { status: 500 })
}
return NextResponse.json({ data })
}
export async function POST(
request: Request,
{ params }: { params: Promise<{ id: string }> },
) {
const { id: employeeId } = await params
const supabase = await createClient()
const { data: { user } } = await supabase.auth.getUser()
if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
const writeCheck = await requireWritePermission(supabase, user.id)
if (!writeCheck.ok) return writeCheck.response
const companyId = await requireCompanyId(supabase, user.id)
const employee = await loadEmployee(supabase, employeeId, companyId)
if (!employee) {
return NextResponse.json({ error: 'Anställd hittades inte' }, { status: 404 })
}
const validation = await validateBody(request, UpsertAbsenceDaySchema)
if (!validation.success) return validation.response
const body = validation.data
// Upsert via DELETE+INSERT on the natural key (employee, date, type) so the
// notes/hours/run-link can be replaced cleanly. The unique index makes ON
// CONFLICT viable too, but Supabase's typed client doesn't expose
// onConflict for our composite key without a named constraint name —
// delete-then-insert keeps the pattern consistent with token-store.ts.
const { error: deleteError } = await supabase
.from('salary_absence_days')
.delete()
.eq('company_id', companyId)
.eq('employee_id', employeeId)
.eq('absence_date', body.absence_date)
.eq('absence_type', body.absence_type)
if (deleteError) {
return NextResponse.json({ error: deleteError.message }, { status: 500 })
}
const { data, error } = await supabase
.from('salary_absence_days')
.insert({
company_id: companyId,
employee_id: employeeId,
absence_date: body.absence_date,
absence_type: body.absence_type,
hours: body.hours,
notes: body.notes ?? null,
salary_run_employee_id: body.salary_run_employee_id ?? null,
})
.select()
.single()
if (error) {
return NextResponse.json({ error: error.message }, { status: 500 })
}
return NextResponse.json({ data }, { status: 201 })
}
// Two modes: ?date=YYYY-MM-DD&type=... (single row) or ?from=…&to=… (range).
// We don't reuse AbsenceRangeQuerySchema.partial() because Zod refuses
// `.partial()` on a schema with refinements (the from<=to check).
const DeleteQuerySchema = z.object({
from: isoDate.optional(),
to: isoDate.optional(),
date: isoDate.optional(),
type: AbsenceTypeSchema.optional(),
})
export async function DELETE(
request: Request,
{ params }: { params: Promise<{ id: string }> },
) {
const { id: employeeId } = await params
const supabase = await createClient()
const { data: { user } } = await supabase.auth.getUser()
if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
const writeCheck = await requireWritePermission(supabase, user.id)
if (!writeCheck.ok) return writeCheck.response
const companyId = await requireCompanyId(supabase, user.id)
const employee = await loadEmployee(supabase, employeeId, companyId)
if (!employee) {
return NextResponse.json({ error: 'Anställd hittades inte' }, { status: 404 })
}
const query = validateQuery(request, DeleteQuerySchema)
if (!query.success) return query.response
const { date, type, from, to } = query.data
// Two delete modes: a single (date, type) row, or a date range.
const hasSingle = !!date
const hasRange = !!from && !!to
if (!hasSingle && !hasRange) {
return NextResponse.json(
{ error: 'Ange antingen ?date=YYYY-MM-DD&type=... eller ?from=...&to=...' },
{ status: 400 },
)
}
let q = supabase
.from('salary_absence_days')
.delete()
.eq('company_id', companyId)
.eq('employee_id', employeeId)
if (hasSingle) {
q = q.eq('absence_date', date!)
if (type) q = q.eq('absence_type', type)
} else {
q = q.gte('absence_date', from!).lte('absence_date', to!)
if (type) q = q.eq('absence_type', type)
}
const { error } = await q
if (error) {
return NextResponse.json({ error: error.message }, { status: 500 })
}
return NextResponse.json({ data: { ok: true } })
}
+56 -4
View File
@@ -71,10 +71,12 @@ export async function GET(
.eq('id', user.id)
.single()
// Load employees with their data
// Load employees with their data. We need monthly_salary on the employee
// record to derive FK499 sjuklönekostnad from per-day records (the line
// item `amount` is the net deduction, not the sjuklön cost).
const { data: runEmployees } = await supabase
.from('salary_run_employees')
.select('*, employee:employees(personnummer, specification_number, f_skatt_status), line_items:salary_line_items(*)')
.select('*, employee:employees(personnummer, specification_number, f_skatt_status, monthly_salary), line_items:salary_line_items(*)')
.eq('salary_run_id', id)
if (!runEmployees || runEmployees.length === 0) {
@@ -94,6 +96,36 @@ export async function GET(
contactEmail: (settings?.email || profile?.email || user.email || '').trim(),
}
// Load per-day absence records for VAB + parental in the pay period.
// Sick days never reach AGI (they go to Försäkringskassan separately).
// The XML generator's Frånvarouppgift section consumes these per event.
const periodStart = `${run.period_year}-${String(run.period_month).padStart(2, '0')}-01`
const periodEndDate = new Date(Date.UTC(run.period_year, run.period_month, 0))
const periodEnd = periodEndDate.toISOString().slice(0, 10)
const employeeIds = runEmployees
.map(sre => sre.employee_id as string)
.filter(Boolean)
const absenceByEmployee = new Map<string, Array<{ date: string; type: 'vab' | 'parental'; hours: number }>>()
if (employeeIds.length > 0) {
const { data: absenceRows } = await supabase
.from('salary_absence_days')
.select('employee_id, absence_date, absence_type, hours')
.eq('company_id', companyId)
.in('absence_type', ['vab', 'parental'])
.gte('absence_date', periodStart)
.lte('absence_date', periodEnd)
.in('employee_id', employeeIds)
for (const row of (absenceRows ?? [])) {
const list = absenceByEmployee.get(row.employee_id) ?? []
list.push({
date: row.absence_date as string,
type: row.absence_type as 'vab' | 'parental',
hours: Number(row.hours ?? 8),
})
absenceByEmployee.set(row.employee_id, list)
}
}
const employeeData: AGIEmployeeData[] = runEmployees.map(sre => {
const emp = sre.employee as { personnummer: string; specification_number: number; f_skatt_status: string } | null
const lineItems = (sre.line_items || []) as Array<Record<string, unknown>>
@@ -104,6 +136,8 @@ export async function GET(
const benefitHousing = sumLineItemAmounts(lineItems, ['benefit_housing'])
const benefitOther = sumLineItemAmounts(lineItems, ['benefit_wellness', 'benefit_other'])
const absenceEvents = absenceByEmployee.get(sre.employee_id as string)
return {
personnummer: emp?.personnummer || '',
specificationNumber: emp?.specification_number || 0,
@@ -118,6 +152,7 @@ export async function GET(
sickDays: sre.sick_days > 0 ? sre.sick_days : undefined,
vabDays: sre.vab_days > 0 ? sre.vab_days : undefined,
parentalDays: sre.parental_days > 0 ? sre.parental_days : undefined,
absenceEvents: absenceEvents && absenceEvents.length > 0 ? absenceEvents : undefined,
}
})
@@ -140,15 +175,32 @@ export async function GET(
0
)
// FK499 TotalSjuklonekostnad — sum of sjuklön paid (days 2–14) across all
// FK499 TotalSjuklonekostnad — sum of *paid* sjuklön (days 2–14) across all
// employees. Day 1 is karens (unpaid); day 15+ is Försäkringskassan, so
// neither counts as an employer sjuklön cost.
//
// Sjuklön cost = dailyRate × sjuklonRate × day-2-14 count.
// The line item `amount` is the *net deduction* (lostPay − sjuklön), not
// the cost — using its quantity field plus the employee's monthly salary
// gives the correct sjuklön cost regardless of the line-item amount
// convention.
//
// sjuklonRate is read from the run's calculation_params snapshot (taken at
// calc time), so an operator override (e.g. for a CBA-specific rate) is
// honored. Falls back to 0.80 (Sjuklönelagen default) for older runs that
// don't have the snapshot.
const calcParams = (run.calculation_params ?? {}) as { sjuklonRate?: number; sjuklon_rate?: number }
const sjuklonRate = calcParams.sjuklonRate ?? calcParams.sjuklon_rate ?? 0.80
let totalSjuklonekostnad = 0
for (const sre of runEmployees) {
const monthly = (sre.employee as { monthly_salary?: number } | null)?.monthly_salary ?? 0
if (!monthly) continue
const dailyRate = monthly / 21
const lineItems = (sre.line_items || []) as Array<Record<string, unknown>>
for (const li of lineItems) {
if (li.item_type === 'sick_day2_14') {
totalSjuklonekostnad += Math.abs((li.amount as number) || 0)
const days = (li.quantity as number) || 0
totalSjuklonekostnad += dailyRate * sjuklonRate * days
}
}
}
+98 -18
View File
@@ -6,8 +6,18 @@ import { requireWritePermission } from '@/lib/auth/require-write'
import { calculateSalary } from '@/lib/salary/calculation-engine'
import { loadPayrollConfig, serializePayrollConfig } from '@/lib/salary/payroll-config'
import { fetchAllTaxTableRatesForRun, TaxTableUnavailableError } from '@/lib/salary/tax-tables'
import { loadAndDeriveAbsence } from '@/lib/salary/derive-absence-line-items'
import { getLineItemAccount } from '@/lib/salary/account-mapping'
import type { SalaryLineItemType } from '@/types'
const DERIVED_ABSENCE_TYPES: SalaryLineItemType[] = [
'sick_karens',
'sick_day2_14',
'sick_day15_plus',
'vab',
'parental_leave',
]
ensureInitialized()
export async function POST(
@@ -125,19 +135,91 @@ export async function POST(
ytdByEmployee.set(prior.employee_id, current)
}
// Pay period bounds — used to load per-day absence records.
const periodYear = run.period_year as number
const periodMonth = run.period_month as number
const periodStart = `${periodYear}-${String(periodMonth).padStart(2, '0')}-01`
const periodEndDate = new Date(Date.UTC(periodYear, periodMonth, 0)) // last day of month
const periodEnd = periodEndDate.toISOString().slice(0, 10)
for (const sre of runEmployees) {
const emp = sre.employee
if (!emp) continue
const lineItems = (sre.line_items || []).map((li: Record<string, unknown>) => ({
// ── Derive absence line items from per-day records ─────────────────
// Sjuklöneperiod boundaries, återinsjuknande, högriskskydd, day-15
// FK transition all require dates — we can't compute them from
// aggregated quantities. Replace any existing derived absence rows on
// this sre with the freshly-computed ones, then merge into the
// in-memory lineItems array passed to calculateSalary.
const absenceResult = await loadAndDeriveAbsence({
supabase,
companyId,
employeeId: emp.id,
monthlySalary: emp.monthly_salary || 0,
payrollConfig: config,
periodStart,
periodEnd,
})
const { error: delAbsErr } = await supabase
.from('salary_line_items')
.delete()
.eq('salary_run_employee_id', sre.id)
.in('item_type', DERIVED_ABSENCE_TYPES)
if (delAbsErr) {
return NextResponse.json({ error: delAbsErr.message }, { status: 500 })
}
if (absenceResult.lineItems.length > 0) {
const rows = absenceResult.lineItems.map((li, idx) => ({
salary_run_employee_id: sre.id,
company_id: companyId,
item_type: li.item_type,
description: li.description,
quantity: li.quantity,
amount: Math.round(li.amount * 100) / 100,
is_taxable: li.is_taxable,
is_avgift_basis: li.is_avgift_basis,
is_vacation_basis: li.is_vacation_basis,
is_gross_deduction: li.is_gross_deduction,
is_net_deduction: false,
account_number: getLineItemAccount(li.item_type),
sort_order: 100 + idx, // sort derived items after manual ones
}))
const { error: insAbsErr } = await supabase
.from('salary_line_items')
.insert(rows)
if (insAbsErr) {
return NextResponse.json({ error: insAbsErr.message }, { status: 500 })
}
}
// Build the merged in-memory line items: keep non-derived items from
// the originally-loaded sre.line_items, then append the freshly-derived
// absence items.
const manualLineItems = (sre.line_items || [])
.filter((li: Record<string, unknown>) =>
!DERIVED_ABSENCE_TYPES.includes(li.item_type as SalaryLineItemType))
.map((li: Record<string, unknown>) => ({
itemType: li.item_type as SalaryLineItemType,
amount: li.amount as number,
isTaxable: li.is_taxable as boolean,
isAvgiftBasis: li.is_avgift_basis as boolean,
isVacationBasis: li.is_vacation_basis as boolean,
isGrossDeduction: li.is_gross_deduction as boolean,
isNetDeduction: li.is_net_deduction as boolean,
}))
const derivedLineItems = absenceResult.lineItems.map(li => ({
itemType: li.item_type as SalaryLineItemType,
amount: li.amount as number,
isTaxable: li.is_taxable as boolean,
isAvgiftBasis: li.is_avgift_basis as boolean,
isVacationBasis: li.is_vacation_basis as boolean,
isGrossDeduction: li.is_gross_deduction as boolean,
isNetDeduction: li.is_net_deduction as boolean,
amount: li.amount,
isTaxable: li.is_taxable,
isAvgiftBasis: li.is_avgift_basis,
isVacationBasis: li.is_vacation_basis,
isGrossDeduction: li.is_gross_deduction,
isNetDeduction: false,
}))
const lineItems = [...manualLineItems, ...derivedLineItems]
const result = calculateSalary(
{
@@ -175,17 +257,15 @@ export async function POST(
}))
)
// Count absence days from line items
const rawLines = (sre.line_items || []) as Array<Record<string, unknown>>
function sumQuantity(types: string[]): number {
return rawLines
.filter(li => types.includes(li.item_type as string))
.reduce((sum: number, li) => sum + ((li.quantity as number) || 0), 0)
}
const sickDays = sumQuantity(['sick_karens', 'sick_day2_14'])
const vabDays = sumQuantity(['vab'])
const parentalDays = sumQuantity(['parental_leave'])
const vacationDays = sumQuantity(['vacation'])
// Aggregated absence counts derived from per-day records (above).
// Vacation still comes from line items because it's user-entered, not
// calendar-tracked yet.
const sickDays = absenceResult.aggregated.sickDays
const vabDays = absenceResult.aggregated.vabDays
const parentalDays = absenceResult.aggregated.parentalDays
const vacationDays = (sre.line_items || [])
.filter((li: Record<string, unknown>) => li.item_type === 'vacation')
.reduce((sum: number, li: Record<string, unknown>) => sum + ((li.quantity as number) || 0), 0)
// Update salary_run_employee with calculated results. If any individual
// update fails we abort so run totals aren't written from partial data.
@@ -6,6 +6,36 @@ import { requireWritePermission } from '@/lib/auth/require-write'
ensureInitialized()
/** Fetch one employee's pay spec within a salary run, with employee + line items. */
export async function GET(
request: Request,
{ params }: { params: Promise<{ id: string; employeeId: string }> },
) {
const { id, employeeId } = await params
const supabase = await createClient()
const { data: { user } } = await supabase.auth.getUser()
if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
const companyId = await requireCompanyId(supabase, user.id)
const { data, error } = await supabase
.from('salary_run_employees')
.select('*, employee:employees(*), line_items:salary_line_items(*)')
.eq('salary_run_id', id)
.eq('employee_id', employeeId)
.eq('company_id', companyId)
.maybeSingle()
if (error) {
return NextResponse.json({ error: error.message }, { status: 500 })
}
if (!data) {
return NextResponse.json({ error: 'Anställd hittades inte i lönekörningen' }, { status: 404 })
}
return NextResponse.json({ data })
}
/** Remove employee from a draft salary run. Cascades to delete their line items. */
export async function DELETE(
request: Request,
+19
View File
@@ -3,6 +3,7 @@ import { NextResponse } from 'next/server'
import { ensureInitialized } from '@/lib/init'
import { requireCompanyId } from '@/lib/company/context'
import { requireWritePermission } from '@/lib/auth/require-write'
import { formatRedovisare } from '@/lib/skatteverket/format'
ensureInitialized()
@@ -35,9 +36,27 @@ export async function GET(
.eq('salary_run_id', id)
.order('created_at')
// Resolve Skatteverket arbetsgivare ID for AGI submission. We surface this
// in the run payload so the client doesn't need a second round-trip just to
// build extension URLs. Quietly null when the org number isn't set yet.
let arbetsgivare: string | null = null
const { data: settings } = await supabase
.from('company_settings')
.select('org_number, entity_type')
.eq('company_id', companyId)
.maybeSingle()
if (settings?.org_number && settings?.entity_type) {
try {
arbetsgivare = formatRedovisare(settings.org_number, settings.entity_type)
} catch {
arbetsgivare = null
}
}
return NextResponse.json({
data: {
...run,
arbetsgivare,
employees: (employees || []).map(emp => ({
...emp,
employee: emp.employee ? {
+491
View File
@@ -0,0 +1,491 @@
'use client'
import { useCallback, useEffect, useState } from 'react'
import {
AlertCircle,
CheckCircle2,
Download,
ExternalLink,
FileCheck,
Link2,
Link2Off,
Loader2,
Lock,
Send,
ShieldAlert,
Unlock,
} from 'lucide-react'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
interface AGIPanelProps {
salaryRunId: string
/** Skatteverket arbetsgivare ID (12-digit) — formatted by parent. */
arbetsgivare: string
/** YYYYMM */
period: string
/** Already-cached run-level signals for showing what step we're at. */
agiGeneratedAt?: string | null
agiSubmittedAt?: string | null
/** When true, write actions are hidden. */
readOnly?: boolean
/** Called after a state-changing action so parent can refresh. */
onChange?: () => void
}
interface ConnectionStatus {
connected: boolean
expired?: boolean
canRefresh?: boolean
scope?: string
expiresAt?: string
}
interface KontrollResult {
kod: string
status: 'ERROR' | 'WARNING'
beskrivning: string
}
interface SubmissionState {
status?: 'draft_saved' | 'draft_locked' | 'signed'
signeringslank?: string
kvittensnummer?: string
tidpunkt?: string
inlamningId?: string
}
const ENABLED_KEY = 'EXTENSION_DISABLED'
export function AGIPanel(props: AGIPanelProps) {
const {
salaryRunId,
arbetsgivare,
period,
agiGeneratedAt,
agiSubmittedAt,
readOnly,
onChange,
} = props
const [extensionDisabled, setExtensionDisabled] = useState(false)
const [status, setStatus] = useState<ConnectionStatus | null>(null)
const [submission, setSubmission] = useState<SubmissionState | null>(null)
const [kontroller, setKontroller] = useState<KontrollResult[]>([])
const [loading, setLoading] = useState(true)
const [actionLoading, setActionLoading] = useState<string | null>(null)
const [error, setError] = useState<string | null>(null)
const [success, setSuccess] = useState<string | null>(null)
const fetchStatus = useCallback(async () => {
setLoading(true)
try {
const res = await fetch('/api/extensions/ext/skatteverket/status')
if (res.status === 503) {
const data = await res.json().catch(() => ({}))
if (data?.code === ENABLED_KEY) {
setExtensionDisabled(true)
return
}
}
if (res.ok) {
setStatus(await res.json())
}
} catch {
// ignore — UI shows the not-connected state
} finally {
setLoading(false)
}
}, [])
const fetchSubmission = useCallback(async () => {
try {
const res = await fetch(
`/api/extensions/ext/skatteverket/agi/status?period=${period}`,
)
if (res.ok) {
const json = await res.json()
setSubmission(json.data ?? null)
}
} catch {
// ignore
}
}, [period])
useEffect(() => {
fetchStatus()
fetchSubmission()
}, [fetchStatus, fetchSubmission])
const handleConnect = () => {
window.location.href = '/api/extensions/ext/skatteverket/authorize'
}
const handleValidate = async () => {
setActionLoading('validate')
setError(null)
setSuccess(null)
setKontroller([])
try {
const res = await fetch('/api/extensions/ext/skatteverket/agi/validate', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ salaryRunId }),
})
const json = await res.json()
if (!res.ok || json.error) {
setError(json.error || `Validering misslyckades (${res.status})`)
return
}
const controls: KontrollResult[] = json.data?.kontrollresultat?.resultat ?? []
setKontroller(controls)
const errs = controls.filter(c => c.status === 'ERROR')
if (errs.length === 0) setSuccess('Valideringen godkänd')
else setError(`${errs.length} valideringsfel hittades`)
} catch (e) {
setError(e instanceof Error ? e.message : 'Kunde inte validera AGI')
} finally {
setActionLoading(null)
}
}
const handleSaveDraft = async () => {
setActionLoading('draft')
setError(null)
setSuccess(null)
try {
const res = await fetch('/api/extensions/ext/skatteverket/agi/draft', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ salaryRunId }),
})
const json = await res.json()
if (!res.ok || json.error) {
setError(json.error || `Kunde inte spara utkast (${res.status})`)
return
}
setSuccess('AGI-utkast sparat hos Skatteverket')
await fetchSubmission()
onChange?.()
} catch (e) {
setError(e instanceof Error ? e.message : 'Kunde inte spara utkast')
} finally {
setActionLoading(null)
}
}
const handleLock = async () => {
setActionLoading('lock')
setError(null)
setSuccess(null)
try {
const res = await fetch(
`/api/extensions/ext/skatteverket/agi/lock?arbetsgivare=${encodeURIComponent(arbetsgivare)}&period=${period}`,
{ method: 'PUT' },
)
const json = await res.json()
if (!res.ok || json.error) {
setError(json.error || `Kunde inte låsa AGI (${res.status})`)
return
}
setSuccess('AGI låst — öppna signeringslänken för att signera med BankID.')
await fetchSubmission()
} catch (e) {
setError(e instanceof Error ? e.message : 'Kunde inte låsa AGI')
} finally {
setActionLoading(null)
}
}
const handleUnlock = async () => {
setActionLoading('unlock')
setError(null)
setSuccess(null)
try {
const res = await fetch(
`/api/extensions/ext/skatteverket/agi/lock?arbetsgivare=${encodeURIComponent(arbetsgivare)}&period=${period}`,
{ method: 'DELETE' },
)
const json = await res.json()
if (!res.ok || json.error) {
setError(json.error || `Kunde inte låsa upp (${res.status})`)
return
}
setSuccess('AGI har låsts upp')
await fetchSubmission()
} catch (e) {
setError(e instanceof Error ? e.message : 'Kunde inte låsa upp')
} finally {
setActionLoading(null)
}
}
const handleCheckSubmitted = async () => {
setActionLoading('check')
setError(null)
setSuccess(null)
try {
const res = await fetch(
`/api/extensions/ext/skatteverket/agi/submitted?arbetsgivare=${encodeURIComponent(arbetsgivare)}&period=${period}`,
)
const json = await res.json()
if (!res.ok || json.error) {
setError(json.error || 'Kunde inte hämta inlämningsstatus')
return
}
if (json.data?.kvittensnummer) {
setSuccess('AGI har lämnats in')
} else {
setSuccess('Ingen inlämning hittades än för perioden')
}
await fetchSubmission()
onChange?.()
} catch (e) {
setError(e instanceof Error ? e.message : 'Kunde inte kontrollera status')
} finally {
setActionLoading(null)
}
}
// ── Render branches ─────────────────────────────────────────────
if (extensionDisabled) {
return (
<Card>
<CardHeader>
<CardTitle className="text-base">Arbetsgivardeklaration (AGI)</CardTitle>
</CardHeader>
<CardContent className="space-y-2 text-sm text-muted-foreground">
<div className="flex items-start gap-2">
<ShieldAlert className="mt-0.5 h-4 w-4 shrink-0" />
<p>
Skatteverket-integrationen är inaktiverad i denna miljö. Aktivera
<code className="mx-1 rounded bg-muted px-1 py-0.5 text-xs">SKATTEVERKET_ENABLED</code>
för att skicka AGI direkt till Skatteverket.
</p>
</div>
</CardContent>
</Card>
)
}
if (loading) {
return (
<Card>
<CardHeader>
<CardTitle className="text-base">Arbetsgivardeklaration (AGI)</CardTitle>
</CardHeader>
<CardContent className="text-sm text-muted-foreground">
<Loader2 className="mr-2 inline h-4 w-4 animate-spin" /> Hämtar Skatteverket-status...
</CardContent>
</Card>
)
}
if (!status?.connected) {
return (
<Card>
<CardHeader>
<CardTitle className="text-base">Arbetsgivardeklaration (AGI)</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
<p className="text-sm text-muted-foreground">
Anslut till Skatteverket med BankID för att skicka AGI direkt från {`gnubok`}.
</p>
{!readOnly && (
<Button onClick={handleConnect}>
<Link2 className="mr-2 h-4 w-4" />
Anslut med BankID
</Button>
)}
</CardContent>
</Card>
)
}
const subState = submission?.status
const isLocked = subState === 'draft_locked'
const isSigned = subState === 'signed' || !!agiSubmittedAt
return (
<Card>
<CardHeader>
<CardTitle className="flex items-center justify-between text-base">
<span>Arbetsgivardeklaration (AGI)</span>
<span className="flex items-center gap-1 text-xs font-normal text-muted-foreground">
<CheckCircle2 className="h-3.5 w-3.5 text-emerald-600" />
Ansluten
</span>
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
{/* Status summary */}
<div className="space-y-1.5 text-sm">
<StatusRow
ok={!!agiGeneratedAt}
okText={agiGeneratedAt ? `AGI-fil genererad ${new Date(agiGeneratedAt).toLocaleString('sv-SE')}` : ''}
pendingText="AGI-fil har inte genererats ännu."
/>
<StatusRow
ok={isSigned}
okText={
submission?.kvittensnummer
? `Skickad till Skatteverket — kvittens ${submission.kvittensnummer}`
: agiSubmittedAt
? `Skickad till Skatteverket ${new Date(agiSubmittedAt).toLocaleString('sv-SE')}`
: 'Skickad'
}
pendingText={
isLocked
? 'AGI låst — väntar på BankID-signatur.'
: subState === 'draft_saved'
? 'Utkast sparat hos Skatteverket. Lås och signera för att slutföra.'
: 'Inte skickad till Skatteverket ännu. Deadline: 12:e i månaden efter utbetalning.'
}
/>
</div>
{submission?.signeringslank && isLocked && (
<div className="rounded-md border border-amber-200 bg-amber-50 p-3 dark:border-amber-900/40 dark:bg-amber-900/20">
<p className="text-sm font-medium">Utkastet är låst och redo att signeras</p>
<p className="mt-0.5 text-xs text-muted-foreground">
Öppna länken nedan och signera med BankID på Skatteverkets sida.
</p>
<a
href={submission.signeringslank}
target="_blank"
rel="noreferrer"
className="mt-2 inline-flex items-center gap-1 text-sm font-medium text-amber-900 hover:underline dark:text-amber-200"
>
Öppna signeringslänk <ExternalLink className="h-3.5 w-3.5" />
</a>
</div>
)}
{kontroller.length > 0 && (
<div className="space-y-1 rounded-md border bg-muted/30 p-2.5">
{kontroller.map((k, i) => (
<div
key={i}
className={`flex items-start gap-2 text-xs ${
k.status === 'ERROR' ? 'text-destructive' : 'text-amber-700 dark:text-amber-400'
}`}
>
<AlertCircle className="mt-0.5 h-3.5 w-3.5 shrink-0" />
<span>
<span className="font-mono">{k.kod}</span> — {k.beskrivning}
</span>
</div>
))}
</div>
)}
{error && (
<div className="rounded-md bg-destructive/10 p-2.5 text-sm text-destructive">
<AlertCircle className="mr-1 inline h-3.5 w-3.5" />
{error}
</div>
)}
{success && !error && (
<div className="rounded-md bg-emerald-50 p-2.5 text-sm text-emerald-900 dark:bg-emerald-900/20 dark:text-emerald-300">
<CheckCircle2 className="mr-1 inline h-3.5 w-3.5" />
{success}
</div>
)}
{!readOnly && !isSigned && (
<div className="flex flex-wrap gap-2">
<Button
size="sm"
variant="outline"
onClick={handleValidate}
disabled={!!actionLoading}
>
{actionLoading === 'validate' ? (
<Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" />
) : (
<FileCheck className="mr-1.5 h-3.5 w-3.5" />
)}
Validera
</Button>
<Button
size="sm"
variant="outline"
onClick={handleSaveDraft}
disabled={!!actionLoading || isLocked}
>
{actionLoading === 'draft' ? (
<Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" />
) : (
<Send className="mr-1.5 h-3.5 w-3.5" />
)}
Spara utkast
</Button>
{!isLocked ? (
<Button
size="sm"
onClick={handleLock}
disabled={!!actionLoading || subState !== 'draft_saved'}
>
{actionLoading === 'lock' ? (
<Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" />
) : (
<Lock className="mr-1.5 h-3.5 w-3.5" />
)}
Lås för signering
</Button>
) : (
<Button
size="sm"
variant="outline"
onClick={handleUnlock}
disabled={!!actionLoading}
>
{actionLoading === 'unlock' ? (
<Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" />
) : (
<Unlock className="mr-1.5 h-3.5 w-3.5" />
)}
Lås upp
</Button>
)}
<Button
size="sm"
variant="ghost"
onClick={handleCheckSubmitted}
disabled={!!actionLoading}
>
{actionLoading === 'check' ? (
<Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" />
) : (
<Download className="mr-1.5 h-3.5 w-3.5" />
)}
Hämta status
</Button>
</div>
)}
</CardContent>
</Card>
)
}
function StatusRow({
ok,
okText,
pendingText,
}: {
ok: boolean
okText: string
pendingText: string
}) {
return (
<div className="flex items-center gap-2">
{ok ? (
<CheckCircle2 className="h-4 w-4 text-emerald-600" />
) : (
<Link2Off className="h-4 w-4 text-muted-foreground" />
)}
<span className="text-muted-foreground">{ok ? okText : pendingText}</span>
</div>
)
}
+445
View File
@@ -0,0 +1,445 @@
'use client'
import { useEffect, useMemo, useState } from 'react'
import {
addDays,
endOfMonth,
format,
isSameDay,
parseISO,
startOfMonth,
startOfWeek,
} from 'date-fns'
import { sv } from 'date-fns/locale'
import {
Activity,
Baby,
ChevronLeft,
ChevronRight,
Heart,
HeartPulse,
Loader2,
Trash2,
type LucideIcon,
} from 'lucide-react'
import { Button } from '@/components/ui/button'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import { cn } from '@/lib/utils'
// ─── Types ─────────────────────────────────────────────────────────
type AbsenceType =
| 'sick'
| 'vab'
| 'parental'
| 'pregnancy'
| 'care_relative'
| 'study'
| 'other_leave'
interface AbsenceDay {
id: string
absence_date: string
absence_type: AbsenceType
hours: number
notes: string | null
}
interface AbsenceTypeMeta {
label: string
shortLabel: string
icon: LucideIcon
// Background dot color tokens — paired with icons so color isn't sole indicator (WCAG AA).
dotClass: string
}
const TYPE_META: Record<AbsenceType, AbsenceTypeMeta> = {
sick: { label: 'Sjukfrånvaro', shortLabel: 'Sjuk', icon: HeartPulse, dotClass: 'bg-red-400' },
vab: { label: 'VAB', shortLabel: 'VAB', icon: Baby, dotClass: 'bg-amber-400' },
parental: { label: 'Föräldraledighet', shortLabel: 'Förä.', icon: Heart, dotClass: 'bg-emerald-400' },
pregnancy: { label: 'Graviditetspenning', shortLabel: 'Grav.', icon: Heart, dotClass: 'bg-pink-400' },
care_relative: { label: 'Närståendepenning', shortLabel: 'Närst.', icon: Heart, dotClass: 'bg-blue-400' },
study: { label: 'Studieledig', shortLabel: 'Studie', icon: Activity, dotClass: 'bg-indigo-400' },
other_leave: { label: 'Övrig ledighet', shortLabel: 'Övrigt', icon: Activity, dotClass: 'bg-zinc-400' },
}
const TYPE_ORDER: AbsenceType[] = ['sick', 'vab', 'parental', 'pregnancy', 'care_relative', 'study', 'other_leave']
// ─── Component ─────────────────────────────────────────────────────
export interface AbsenceCalendarProps {
employeeId: string
/** Pay period start (YYYY-MM-DD). The calendar opens on this month. */
periodStart: string
/** Pay period end (YYYY-MM-DD). Days outside the period are still
* visible (and editable, since absence is per-employee not per-run)
* but visually muted. */
periodEnd: string
/** Optional: link new absence rows to a specific salary run. */
salaryRunEmployeeId?: string
/** When true, calendar is read-only (e.g. for booked runs). */
readOnly?: boolean
/** Called after a successful create/delete so the parent can refresh
* derived totals. */
onChange?: () => void
}
export function AbsenceCalendar({
employeeId,
periodStart,
periodEnd,
salaryRunEmployeeId,
readOnly = false,
onChange,
}: AbsenceCalendarProps) {
const periodStartDate = useMemo(() => parseISO(periodStart), [periodStart])
const periodEndDate = useMemo(() => parseISO(periodEnd), [periodEnd])
const [visibleMonth, setVisibleMonth] = useState<Date>(() => startOfMonth(periodStartDate))
const [days, setDays] = useState<AbsenceDay[]>([])
const [loading, setLoading] = useState(false)
const [editing, setEditing] = useState<{ date: string; existing?: AbsenceDay } | null>(null)
const [error, setError] = useState<string | null>(null)
// Pad to a 6-week grid starting on Monday (Swedish week).
const gridStart = startOfWeek(startOfMonth(visibleMonth), { weekStartsOn: 1 })
const loadAbsences = async () => {
setLoading(true)
setError(null)
try {
const from = format(gridStart, 'yyyy-MM-dd')
const to = format(addDays(gridStart, 41), 'yyyy-MM-dd')
const res = await fetch(
`/api/salary/employees/${employeeId}/absence?from=${from}&to=${to}`,
)
const json = await res.json()
if (!res.ok) {
throw new Error(json.error || 'Kunde inte ladda frånvaro')
}
setDays(json.data ?? [])
} catch (e) {
setError(e instanceof Error ? e.message : 'Okänt fel')
} finally {
setLoading(false)
}
}
useEffect(() => {
loadAbsences()
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [employeeId, visibleMonth.getFullYear(), visibleMonth.getMonth()])
const dayMap = useMemo(() => {
const m = new Map<string, AbsenceDay[]>()
for (const d of days) {
const key = d.absence_date
const list = m.get(key) ?? []
list.push(d)
m.set(key, list)
}
return m
}, [days])
const cells = useMemo(() => {
return Array.from({ length: 42 }, (_, i) => addDays(gridStart, i))
}, [gridStart])
const handleCellClick = (date: Date) => {
if (readOnly) return
const key = format(date, 'yyyy-MM-dd')
const existing = dayMap.get(key)?.[0] // edit first if multiple types same day
setEditing({ date: key, existing })
}
return (
<div className="rounded-md border bg-card">
{/* Header */}
<div className="flex items-center justify-between gap-2 border-b px-3 py-2">
<div className="flex items-center gap-2">
<Button
variant="ghost"
size="sm"
onClick={() => setVisibleMonth(prev => addDays(startOfMonth(prev), -1))}
aria-label="Föregående månad"
>
<ChevronLeft className="h-4 w-4" />
</Button>
<span className="text-sm font-medium tabular-nums">
{format(visibleMonth, 'MMMM yyyy', { locale: sv })}
</span>
<Button
variant="ghost"
size="sm"
onClick={() => setVisibleMonth(prev => addDays(endOfMonth(prev), 1))}
aria-label="Nästa månad"
>
<ChevronRight className="h-4 w-4" />
</Button>
</div>
{loading && <Loader2 className="h-4 w-4 animate-spin text-muted-foreground" />}
</div>
{/* Weekday header */}
<div className="grid grid-cols-7 border-b bg-muted/40 text-[11px] font-medium uppercase tracking-wide text-muted-foreground">
{['Mån', 'Tis', 'Ons', 'Tor', 'Fre', 'Lör', 'Sön'].map(d => (
<div key={d} className="px-2 py-1.5 text-center">{d}</div>
))}
</div>
{/* Calendar grid */}
<div className="grid grid-cols-7">
{cells.map((date, i) => {
const key = format(date, 'yyyy-MM-dd')
const inMonth = date.getMonth() === visibleMonth.getMonth()
const inPeriod = date >= periodStartDate && date <= periodEndDate
const today = isSameDay(date, new Date())
const dayAbsences = dayMap.get(key) ?? []
return (
<button
type="button"
key={i}
onClick={() => handleCellClick(date)}
disabled={readOnly}
className={cn(
'relative flex h-20 flex-col items-start gap-0.5 border-b border-r p-1.5 text-left text-xs transition-colors',
!readOnly && 'hover:bg-accent/40 focus:outline-none focus-visible:ring-2 focus-visible:ring-ring',
readOnly && 'cursor-default',
!inMonth && 'bg-muted/30 text-muted-foreground/60',
!inPeriod && inMonth && 'bg-muted/10',
today && 'ring-1 ring-inset ring-primary/40',
)}
>
<span className={cn('tabular-nums', today && 'font-semibold')}>
{format(date, 'd')}
</span>
{dayAbsences.length > 0 && (
<div className="mt-auto flex flex-wrap items-center gap-0.5">
{dayAbsences.map(a => {
const meta = TYPE_META[a.absence_type]
const Icon = meta.icon
return (
<span
key={a.id}
className={cn(
'inline-flex items-center gap-0.5 rounded-full px-1 py-px text-[10px] font-medium text-foreground',
meta.dotClass,
)}
title={`${meta.label} (${a.hours}h)`}
>
<Icon className="h-2.5 w-2.5" aria-hidden />
<span>{meta.shortLabel}</span>
</span>
)
})}
</div>
)}
</button>
)
})}
</div>
{/* Legend */}
<div className="flex flex-wrap items-center gap-x-3 gap-y-1.5 border-t px-3 py-2 text-[11px] text-muted-foreground">
{TYPE_ORDER.map(t => {
const meta = TYPE_META[t]
const Icon = meta.icon
return (
<span key={t} className="inline-flex items-center gap-1">
<span className={cn('inline-flex h-3 w-3 items-center justify-center rounded-full', meta.dotClass)}>
<Icon className="h-2 w-2" aria-hidden />
</span>
<span>{meta.label}</span>
</span>
)
})}
</div>
{error && (
<div className="border-t bg-destructive/10 px-3 py-2 text-xs text-destructive">
{error}
</div>
)}
{/* Edit dialog */}
{editing && (
<AbsenceDayDialog
employeeId={employeeId}
salaryRunEmployeeId={salaryRunEmployeeId}
date={editing.date}
existing={editing.existing}
onClose={() => setEditing(null)}
onSaved={() => {
setEditing(null)
loadAbsences()
onChange?.()
}}
/>
)}
</div>
)
}
// ─── Dialog ────────────────────────────────────────────────────────
interface AbsenceDayDialogProps {
employeeId: string
salaryRunEmployeeId?: string
date: string
existing?: AbsenceDay
onClose: () => void
onSaved: () => void
}
function AbsenceDayDialog({
employeeId,
salaryRunEmployeeId,
date,
existing,
onClose,
onSaved,
}: AbsenceDayDialogProps) {
const [absenceType, setAbsenceType] = useState<AbsenceType>(existing?.absence_type ?? 'sick')
const [hours, setHours] = useState<string>(existing?.hours?.toString() ?? '8')
const [notes, setNotes] = useState<string>(existing?.notes ?? '')
const [submitting, setSubmitting] = useState(false)
const [error, setError] = useState<string | null>(null)
const handleSave = async () => {
setSubmitting(true)
setError(null)
try {
const hoursNum = parseFloat(hours)
if (!isFinite(hoursNum) || hoursNum <= 0 || hoursNum > 24) {
throw new Error('Timmar måste vara mellan 0 och 24')
}
const res = await fetch(`/api/salary/employees/${employeeId}/absence`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
absence_date: date,
absence_type: absenceType,
hours: hoursNum,
notes: notes.trim() || undefined,
salary_run_employee_id: salaryRunEmployeeId,
}),
})
const json = await res.json()
if (!res.ok) throw new Error(json.error || 'Kunde inte spara frånvaro')
onSaved()
} catch (e) {
setError(e instanceof Error ? e.message : 'Okänt fel')
} finally {
setSubmitting(false)
}
}
const handleDelete = async () => {
if (!existing) return
setSubmitting(true)
setError(null)
try {
const res = await fetch(
`/api/salary/employees/${employeeId}/absence?date=${date}&type=${existing.absence_type}`,
{ method: 'DELETE' },
)
const json = await res.json()
if (!res.ok) throw new Error(json.error || 'Kunde inte radera frånvaro')
onSaved()
} catch (e) {
setError(e instanceof Error ? e.message : 'Okänt fel')
} finally {
setSubmitting(false)
}
}
return (
<Dialog open onOpenChange={(o) => !o && onClose()}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>
Frånvaro {format(parseISO(date), 'd MMMM yyyy', { locale: sv })}
</DialogTitle>
<DialogDescription>
Välj typ av frånvaro. Sjuklöneberäkning, karensavdrag och AGI-rapportering härleds automatiskt.
</DialogDescription>
</DialogHeader>
<div className="space-y-3">
<div className="space-y-1.5">
<label className="text-xs font-medium">Typ</label>
<Select value={absenceType} onValueChange={v => setAbsenceType(v as AbsenceType)}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{TYPE_ORDER.map(t => (
<SelectItem key={t} value={t}>{TYPE_META[t].label}</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-1.5">
<label className="text-xs font-medium" htmlFor="absence-hours">Timmar</label>
<input
id="absence-hours"
type="number"
min={0.5}
max={24}
step={0.5}
value={hours}
onChange={(e) => setHours(e.target.value)}
className="flex h-9 w-full rounded-md border border-input bg-background px-3 py-1 text-sm tabular-nums shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
/>
</div>
<div className="space-y-1.5">
<label className="text-xs font-medium" htmlFor="absence-notes">Anteckning (valfri)</label>
<textarea
id="absence-notes"
value={notes}
onChange={(e) => setNotes(e.target.value)}
rows={2}
maxLength={2000}
className="flex w-full rounded-md border border-input bg-background px-3 py-1.5 text-sm shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
/>
</div>
{error && (
<div className="rounded-md bg-destructive/10 p-2 text-xs text-destructive">{error}</div>
)}
</div>
<DialogFooter className="gap-2 sm:justify-between">
<div>
{existing && (
<Button variant="outline" size="sm" onClick={handleDelete} disabled={submitting}>
<Trash2 className="mr-1 h-3.5 w-3.5" />
Ta bort
</Button>
)}
</div>
<div className="flex gap-2">
<Button variant="outline" size="sm" onClick={onClose} disabled={submitting}>
Avbryt
</Button>
<Button size="sm" onClick={handleSave} disabled={submitting}>
{submitting && <Loader2 className="mr-1 h-3.5 w-3.5 animate-spin" />}
{existing ? 'Uppdatera' : 'Lägg till'}
</Button>
</div>
</DialogFooter>
</DialogContent>
</Dialog>
)
}
+2
View File
@@ -21,6 +21,7 @@ export function SettingsNav({ isSandbox }: { isSandbox?: boolean }) {
const hasCompany = !!company
const hasBankingExtension = ENABLED_EXTENSION_IDS.has('enable-banking')
const hasMcpExtension = ENABLED_EXTENSION_IDS.has('mcp-server')
const hasSkatteverketExtension = ENABLED_EXTENSION_IDS.has('skatteverket')
const items: NavItem[] = [
{ href: '/settings/company', label: 'Företag', show: hasCompany },
@@ -29,6 +30,7 @@ export function SettingsNav({ isSandbox }: { isSandbox?: boolean }) {
{ href: '/settings/tax', label: 'Skatt', show: hasCompany },
{ href: '/settings/team', label: 'Lag', show: false },
{ href: '/settings/banking', label: 'Bank (PSD2)', show: hasCompany && !isSandbox && hasBankingExtension },
{ href: '/settings/skatteverket', label: 'Skatteverket', show: hasCompany && !isSandbox && hasSkatteverketExtension },
{ href: '/settings/salary', label: 'Löner', show: hasCompany && company?.entity_type === 'aktiebolag' },
{ href: '/settings/templates', label: 'Mallar', show: hasCompany },
{ href: '/settings/backup', label: 'Säkerhetsbackup', show: hasCompany },
@@ -0,0 +1,192 @@
'use client'
import { useEffect, useState } from 'react'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import { useToast } from '@/components/ui/use-toast'
import { CheckCircle2, ExternalLink, ShieldOff } from 'lucide-react'
type Status =
| { connected: false }
| {
connected: true
expired: boolean
canRefresh: boolean
scope: string
expiresAt: string
}
const SCOPE_LABELS: Record<string, string> = {
momsdeklaration: 'Momsdeklaration',
inkforetag: 'Företagsinformation',
ska: 'Skatteinformation',
skahmst: 'Hemortskommun',
skattekonto: 'Skattekonto',
}
export function SkatteverketConnectPanel() {
const { toast } = useToast()
const [status, setStatus] = useState<Status | null>(null)
const [loading, setLoading] = useState(true)
const [disconnecting, setDisconnecting] = useState(false)
async function loadStatus() {
setLoading(true)
try {
const res = await fetch('/api/extensions/ext/skatteverket/status')
if (res.status === 503) {
setStatus({ connected: false })
return
}
const data = (await res.json()) as Status
setStatus(data)
} catch {
setStatus({ connected: false })
} finally {
setLoading(false)
}
}
useEffect(() => {
loadStatus()
}, [])
function startConnect() {
const returnTo = encodeURIComponent('/settings/skatteverket')
window.location.href = `/api/extensions/ext/skatteverket/authorize?return_to=${returnTo}`
}
async function disconnect() {
setDisconnecting(true)
try {
const res = await fetch('/api/extensions/ext/skatteverket/disconnect', {
method: 'POST',
})
if (!res.ok) throw new Error('Frånkoppling misslyckades')
toast({ title: 'Skatteverket frånkopplad' })
await loadStatus()
} catch (err) {
toast({
title: 'Kunde inte koppla från',
description: err instanceof Error ? err.message : undefined,
variant: 'destructive',
})
} finally {
setDisconnecting(false)
}
}
if (loading) {
return (
<Card>
<CardContent className="py-8 text-sm text-muted-foreground">
Hämtar status…
</CardContent>
</Card>
)
}
if (!status?.connected) {
return (
<Card>
<CardHeader>
<CardTitle>Skatteverket</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<p className="text-sm text-muted-foreground">
Anslut till Skatteverket med BankID för att skicka momsdeklaration,
arbetsgivardeklaration och hämta saldot på skattekontot.
</p>
<Button onClick={startConnect}>
<ExternalLink className="mr-2 h-4 w-4" />
Anslut med BankID
</Button>
</CardContent>
</Card>
)
}
const scopes = (status.scope || '').split(/\s+/).filter(Boolean)
const expiresAtDate = new Date(status.expiresAt)
const expiresInMinutes = Math.round(
(expiresAtDate.getTime() - Date.now()) / 60_000,
)
return (
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<CardTitle className="flex items-center gap-2">
Skatteverket
{status.expired ? (
<Badge variant="destructive">Utgången</Badge>
) : (
<Badge variant="secondary">
<CheckCircle2 className="mr-1 h-3 w-3" />
Ansluten
</Badge>
)}
</CardTitle>
</div>
</CardHeader>
<CardContent className="space-y-4">
<dl className="grid grid-cols-1 gap-3 text-sm sm:grid-cols-2">
<div>
<dt className="text-muted-foreground">Token utgår</dt>
<dd className="font-medium tabular-nums">
{expiresAtDate.toLocaleString('sv-SE')}
{!status.expired && expiresInMinutes > 0 && (
<span className="ml-2 text-muted-foreground">
(om {expiresInMinutes} min)
</span>
)}
</dd>
</div>
<div>
<dt className="text-muted-foreground">Förnyelse</dt>
<dd className="font-medium">
{status.canRefresh ? 'Förnyas automatiskt' : 'Förnyelse uttömd — anslut igen'}
</dd>
</div>
</dl>
<div>
<p className="mb-2 text-xs uppercase tracking-wide text-muted-foreground">
Behörigheter
</p>
<div className="flex flex-wrap gap-2">
{scopes.map(s => (
<Badge key={s} variant="outline">
{SCOPE_LABELS[s] ?? s}
</Badge>
))}
</div>
{!scopes.includes('skattekonto') && (
<p className="mt-3 text-sm text-foreground">
Behörigheten för Skattekonto saknas — koppla från och anslut igen
för att aktivera saldo- och transaktionsvyn.
</p>
)}
</div>
<div className="flex gap-2 pt-2">
{(status.expired || !status.canRefresh || !scopes.includes('skattekonto')) && (
<Button onClick={startConnect}>
<ExternalLink className="mr-2 h-4 w-4" />
Anslut igen
</Button>
)}
<Button
variant="outline"
onClick={disconnect}
disabled={disconnecting}
>
<ShieldOff className="mr-2 h-4 w-4" />
{disconnecting ? 'Kopplar från…' : 'Koppla från'}
</Button>
</div>
</CardContent>
</Card>
)
}
@@ -0,0 +1,113 @@
import { describe, it, expect, beforeEach, vi } from 'vitest'
// Mock the token-store to bypass DB and supply a fresh access token.
vi.mock('../lib/token-store', () => ({
getTokens: vi.fn(async () => ({
access_token: 'test-access',
refresh_token: 'test-refresh',
expires_at: Date.now() + 60 * 60_000,
refresh_count: 0,
scope: 'momsdeklaration',
})),
storeTokens: vi.fn(),
deleteTokens: vi.fn(),
}))
// Mock oauth so a refresh attempt (shouldn't fire) is harmless.
vi.mock('../lib/oauth', () => ({
refreshAccessToken: vi.fn(async () => ({
access_token: 'refreshed',
refresh_token: 'refreshed-r',
expires_at: Date.now() + 60 * 60_000,
refresh_count: 1,
})),
exchangeCodeForTokens: vi.fn(),
}))
import { skvRequest, SkatteverketAuthError } from '../lib/api-client'
const fakeSupabase = {} as unknown as Parameters<typeof skvRequest>[0]
beforeEach(() => {
process.env.SKATTEVERKET_APIGW_CLIENT_ID = 'gw-id'
process.env.SKATTEVERKET_APIGW_CLIENT_SECRET = 'gw-secret'
process.env.SKATTEVERKET_API_BASE_URL = 'https://api.test.example/x'
vi.restoreAllMocks()
})
function mockFetchStatus(status: number, body = '') {
global.fetch = vi.fn(async () =>
new Response(body, { status, statusText: String(status) })
) as unknown as typeof fetch
}
describe('skvRequest — error mapping', () => {
it('maps 401 → SESSION_EXPIRED', async () => {
mockFetchStatus(401)
await expect(
skvRequest(fakeSupabase, 'user-1', 'GET', '/x'),
).rejects.toMatchObject({
name: 'SkatteverketAuthError',
code: 'SESSION_EXPIRED',
})
})
it('maps 403 with Behörighet body → BEHORIGHET_SAKNAS', async () => {
mockFetchStatus(403, 'Behörighet saknas för aktören')
try {
await skvRequest(fakeSupabase, 'user-1', 'GET', '/x')
expect.fail('expected throw')
} catch (e) {
expect(e).toBeInstanceOf(SkatteverketAuthError)
expect((e as SkatteverketAuthError).code).toBe('BEHORIGHET_SAKNAS')
}
})
it('maps generic 403 → ACCESS_DENIED', async () => {
mockFetchStatus(403, 'Forbidden')
try {
await skvRequest(fakeSupabase, 'user-1', 'GET', '/x')
expect.fail('expected throw')
} catch (e) {
expect(e).toBeInstanceOf(SkatteverketAuthError)
expect((e as SkatteverketAuthError).code).toBe('ACCESS_DENIED')
}
})
it('maps 429 → RATE_LIMITED (new behavior)', async () => {
mockFetchStatus(429)
try {
await skvRequest(fakeSupabase, 'user-1', 'GET', '/x')
expect.fail('expected throw')
} catch (e) {
expect(e).toBeInstanceOf(SkatteverketAuthError)
expect((e as SkatteverketAuthError).code).toBe('RATE_LIMITED')
// Swedish message — UI surfaces it directly.
expect((e as SkatteverketAuthError).message).toMatch(/Skatteverket/)
expect((e as SkatteverketAuthError).message).toMatch(/igen/i)
}
})
it('returns the response for 5xx (caller decides retry)', async () => {
mockFetchStatus(503, 'Service Unavailable')
const res = await skvRequest(fakeSupabase, 'user-1', 'GET', '/x')
expect(res.status).toBe(503)
})
it('returns the response for success', async () => {
mockFetchStatus(200, '{"ok":true}')
const res = await skvRequest(fakeSupabase, 'user-1', 'GET', '/x')
expect(res.status).toBe(200)
const json = await res.json()
expect(json).toEqual({ ok: true })
})
})
describe('SkatteverketAuthError', () => {
it('exposes the new TOKEN_CORRUPTED and RATE_LIMITED codes', () => {
const a = new SkatteverketAuthError('msg', 'TOKEN_CORRUPTED')
const b = new SkatteverketAuthError('msg', 'RATE_LIMITED')
expect(a.code).toBe('TOKEN_CORRUPTED')
expect(b.code).toBe('RATE_LIMITED')
})
})
@@ -0,0 +1,68 @@
import { describe, it, expect } from 'vitest'
import { guessCounterAccount } from '../lib/skattekonto-booking'
describe('guessCounterAccount', () => {
it('routes "Inbetalning bokförd" to bank account 1930', () => {
const guess = guessCounterAccount('Inbetalning bokförd 240412', 'aktiebolag')
expect(guess?.account).toBe('1930')
})
it('routes refund-style descriptions to 1930', () => {
expect(guessCounterAccount('Utbetalning 1234', 'aktiebolag')?.account).toBe('1930')
expect(guessCounterAccount('Återbetalning av moms', 'aktiebolag')?.account).toBe('1930')
})
it('uses 2510 for AB preliminär skatt and 2012 for EF', () => {
expect(
guessCounterAccount('Debiterad preliminärskatt', 'aktiebolag')?.account,
).toBe('2510')
expect(
guessCounterAccount('Debiterad preliminärskatt', 'enskild_firma')?.account,
).toBe('2012')
})
it('routes employer payroll taxes to 2731', () => {
expect(
guessCounterAccount('Arbetsgivaravgifter januari', 'aktiebolag')?.account,
).toBe('2731')
expect(
guessCounterAccount('Sociala avgifter Q1', 'aktiebolag')?.account,
).toBe('2731')
})
it('routes deducted income tax to 2710', () => {
expect(
guessCounterAccount('Avdragen skatt anställda', 'aktiebolag')?.account,
).toBe('2710')
})
it('routes VAT settlements to 2650', () => {
expect(
guessCounterAccount('Mervärdesskatt mars', 'aktiebolag')?.account,
).toBe('2650')
expect(guessCounterAccount('Moms Q1 2025', 'aktiebolag')?.account).toBe(
'2650',
)
})
it('routes interest to 8423/8313', () => {
expect(
guessCounterAccount('Kostnadsränta skattekonto', 'aktiebolag')?.account,
).toBe('8423')
expect(
guessCounterAccount('Intäktsränta skattekonto', 'aktiebolag')?.account,
).toBe('8313')
})
it('returns null when no keyword matches', () => {
expect(
guessCounterAccount('Något konstigt vi inte känner igen', 'aktiebolag'),
).toBeNull()
})
it('matches case-insensitively', () => {
expect(
guessCounterAccount('INBETALNING BOKFÖRD 240412', 'aktiebolag')?.account,
).toBe('1930')
})
})
@@ -0,0 +1,159 @@
import { describe, it, expect } from 'vitest'
import { computeDedupKey } from '../lib/skattekonto-sync'
import type {
SkatteverketSaldoResponse,
SkatteverketTransaktionerResponse,
} from '../types'
// Fixtures inlined verbatim from Skatteverket's official Skattekonto v2.1.0
// API examples (dev_docs/skattekonto(2.1.0)/examples/*.json). The dev_docs
// directory is gitignored, so the fixtures must live alongside the test.
const saldoResponseExample = {
nastaAvstamningsdatum: '2019-06-01',
senastUppdaterad: '2019-05-06T03:04:05Z',
informationstext: [],
saldoSkatteverket: -14487,
saldoKronofogden: -145409,
rantaSkatteverket: -12,
rantaKronofogden: -10,
ocrNummer: '1948040320946',
}
const transaktionerResponseExample = {
nastaAvstamningsdatum: '2019-06-01',
senastUppdaterad: '2019-05-06T03:04:05Z',
informationstext: ['Test av informationstext'],
ocrNummer: '1948040320946',
datumFrom: '2017-10-28',
tidigareTransaktioner: [
{
transaktionsidentitet: 746876987,
transaktionsdatum: '2019-04-16',
ranteberakningsdatum: '2019-04-13',
transaktionstext: 'Inbetalning bokförd 190412',
beloppSkatteverket: 1292,
},
{
transaktionsidentitet: 746876988,
transaktionsdatum: '2019-04-16',
ranteberakningsdatum: '2019-04-13',
transaktionstext: 'Debiterad preliminärskatt',
beloppSkatteverket: -1292,
},
{
transaktionsidentitet: 746876989,
transaktionsdatum: '2019-04-16',
ranteberakningsdatum: '2019-04-12',
transaktionstext: 'Inbetalning bokförd 190411',
beloppSkatteverket: 5402,
},
{
transaktionsidentitet: 746876990,
transaktionsdatum: '2019-04-16',
ranteberakningsdatum: '2019-04-13',
transaktionstext: 'Avdragen skatt mars 2019',
beloppSkatteverket: -3000,
},
{
transaktionsidentitet: 746876991,
transaktionsdatum: '2019-04-16',
ranteberakningsdatum: '2019-04-13',
transaktionstext: 'Arbetsgivaravgift mars 2019',
beloppSkatteverket: -2402,
},
],
kommandeTransaktioner: [
{
transaktionsdatum: '2019-03-13',
forfallodatum: '2019-05-13',
ranteberakningsdatum: '2019-05-14',
transaktionstext: 'Debiterad prelimniärskatt',
beloppSkatteverket: -1292,
},
],
}
describe('skattekonto example payloads', () => {
it('saldoResponse.json fits SkatteverketSaldoResponse', () => {
const saldo = saldoResponseExample as SkatteverketSaldoResponse
expect(saldo.saldoSkatteverket).toBe(-14487)
expect(saldo.saldoKronofogden).toBe(-145409)
expect(saldo.ocrNummer).toBe('1948040320946')
expect(saldo.nastaAvstamningsdatum).toMatch(/^\d{4}-\d{2}-\d{2}$/)
expect(Array.isArray(saldo.informationstext)).toBe(true)
})
it('transaktionerResponse.json fits SkatteverketTransaktionerResponse', () => {
const tx = transaktionerResponseExample as SkatteverketTransaktionerResponse
expect(Array.isArray(tx.tidigareTransaktioner)).toBe(true)
expect(Array.isArray(tx.kommandeTransaktioner)).toBe(true)
const booked = tx.tidigareTransaktioner[0]
expect(typeof booked.transaktionsidentitet).toBe('number')
expect(booked.transaktionsdatum).toMatch(/^\d{4}-\d{2}-\d{2}$/)
expect(typeof booked.beloppSkatteverket).toBe('number')
})
})
describe('computeDedupKey', () => {
it('uses transaktionsidentitet when present', () => {
const key = computeDedupKey({
transaktionsidentitet: 746876987,
transaktionsdatum: '2019-04-16',
beloppSkatteverket: 1292,
transaktionstext: 'Inbetalning bokförd 190412',
})
expect(key).toBe('id:746876987')
})
it('falls back to a content hash when transaktionsidentitet is missing', () => {
const key = computeDedupKey({
transaktionsidentitet: null,
transaktionsdatum: '2019-05-13',
beloppSkatteverket: -1292,
transaktionstext: 'Debiterad preliminärskatt',
})
expect(key).toMatch(/^h:[0-9a-f]{64}$/)
})
it('produces stable keys for the same content', () => {
const tx = {
transaktionsidentitet: null,
transaktionsdatum: '2019-05-13',
beloppSkatteverket: -1292,
transaktionstext: 'Debiterad preliminärskatt',
}
expect(computeDedupKey(tx)).toBe(computeDedupKey(tx))
})
it('produces different keys for different content', () => {
const a = computeDedupKey({
transaktionsidentitet: null,
transaktionsdatum: '2019-05-13',
beloppSkatteverket: -1292,
transaktionstext: 'A',
})
const b = computeDedupKey({
transaktionsidentitet: null,
transaktionsdatum: '2019-05-13',
beloppSkatteverket: -1292,
transaktionstext: 'B',
})
expect(a).not.toBe(b)
})
it('treats undefined transaktionsidentitet the same as null', () => {
const a = computeDedupKey({
transaktionsdatum: '2019-05-13',
beloppSkatteverket: -1292,
transaktionstext: 'X',
})
const b = computeDedupKey({
transaktionsidentitet: null,
transaktionsdatum: '2019-05-13',
beloppSkatteverket: -1292,
transaktionstext: 'X',
})
expect(a).toBe(b)
})
})
@@ -0,0 +1,91 @@
import { randomUUID } from 'crypto'
import { describe, expect, it } from 'vitest'
import { seedCompany } from '@/tests/pg/fixtures'
import { getPool, withUserContext } from '@/tests/pg/setup'
/**
* RLS smoke for skattekonto_transactions. Locks in tenant isolation +
* the (company_id, dedup_key) unique constraint that the sync UPSERT
* relies on for idempotency.
*/
async function insertSkattekontoTransaction(params: {
companyId: string
dedupKey?: string
date?: string
amount?: number
status?: 'booked' | 'upcoming'
}): Promise<string> {
const id = randomUUID()
await getPool().query(
`INSERT INTO public.skattekonto_transactions
(id, company_id, dedup_key, transaktionsdatum, transaktionstext,
belopp_skatteverket, status)
VALUES ($1, $2, $3, $4, 'Test transaction', $5, $6)`,
[
id,
params.companyId,
params.dedupKey ?? `id:${Math.floor(Math.random() * 1_000_000)}`,
params.date ?? '2026-04-15',
params.amount ?? -1000,
params.status ?? 'booked',
],
)
return id
}
describe('skattekonto_transactions.pg — RLS tenant isolation', () => {
it('a user only sees rows for their own company', async () => {
const a = await seedCompany()
const b = await seedCompany()
await insertSkattekontoTransaction({ companyId: a.companyId, dedupKey: 'id:111' })
await insertSkattekontoTransaction({ companyId: b.companyId, dedupKey: 'id:222' })
const rows = await withUserContext(a.userId, async (client) => {
const res = await client.query<{ company_id: string; dedup_key: string }>(
`SELECT company_id, dedup_key FROM public.skattekonto_transactions`,
)
return res.rows
})
expect(rows).toHaveLength(1)
expect(rows[0]!.company_id).toBe(a.companyId)
expect(rows[0]!.dedup_key).toBe('id:111')
})
it('UPDATE WITH CHECK blocks moving a row to another tenant', async () => {
const a = await seedCompany()
const b = await seedCompany()
await insertSkattekontoTransaction({ companyId: a.companyId, dedupKey: 'id:333' })
// User A authenticates, then tries to set company_id to B's id.
// Must fail under WITH CHECK on UPDATE.
await expect(
withUserContext(a.userId, async (client) => {
return client.query(
`UPDATE public.skattekonto_transactions
SET company_id = $1
WHERE dedup_key = 'id:333'`,
[b.companyId],
)
}),
).rejects.toThrow(/row-level security/i)
})
it('enforces unique (company_id, dedup_key) for UPSERT idempotency', async () => {
const a = await seedCompany()
await insertSkattekontoTransaction({ companyId: a.companyId, dedupKey: 'id:444' })
await expect(
insertSkattekontoTransaction({ companyId: a.companyId, dedupKey: 'id:444' }),
).rejects.toThrow(/duplicate key|unique/i)
})
it('allows the same dedup_key in a different tenant', async () => {
const a = await seedCompany()
const b = await seedCompany()
await insertSkattekontoTransaction({ companyId: a.companyId, dedupKey: 'id:555' })
// Different tenant, same dedup_key — should succeed.
await expect(
insertSkattekontoTransaction({ companyId: b.companyId, dedupKey: 'id:555' }),
).resolves.toBeDefined()
})
})
@@ -0,0 +1,79 @@
import { describe, it, expect, beforeEach, vi } from 'vitest'
// Stub the supabase service-role client so token-store doesn't need real env.
vi.mock('@supabase/supabase-js', () => ({
createClient: vi.fn(() => ({
from: vi.fn(),
})),
}))
import { getTokens } from '../lib/token-store'
const fakeSupabase = {} as never
beforeEach(() => {
process.env.NEXT_PUBLIC_SUPABASE_URL = 'http://localhost'
process.env.SUPABASE_SERVICE_ROLE_KEY = 'service-key'
process.env.SKATTEVERKET_TOKEN_ENCRYPTION_KEY = 'test-encryption-key'
vi.restoreAllMocks()
})
async function mockSelectReturning(row: unknown) {
const { createClient } = await import('@supabase/supabase-js')
;(createClient as unknown as { mockReturnValue: (v: unknown) => void }).mockReturnValue({
from: vi.fn(() => ({
select: vi.fn(() => ({
eq: vi.fn(() => ({
single: vi.fn(async () => row),
})),
})),
})),
})
}
describe('getTokens', () => {
it('returns null when no row exists (NOT_CONNECTED state)', async () => {
await mockSelectReturning({ data: null, error: { message: 'no rows' } })
// Module-level _serviceClient cache means we need a fresh import after
// mock changes. Use vitest's resetModules to force re-import.
vi.resetModules()
const { getTokens: fresh } = await import('../lib/token-store')
const result = await fresh(fakeSupabase, 'user-1')
expect(result).toBeNull()
})
it('throws TOKEN_CORRUPTED when stored ciphertext cannot be decrypted', async () => {
// Arrange: the row exists but its access_token is not valid AES-256-GCM
// ciphertext (e.g. encryption key was rotated). With vi.resetModules()
// the SkatteverketAuthError class identity diverges across re-imports,
// so we assert by .name + .code instead of instanceof.
await mockSelectReturning({
data: {
access_token: 'this-is-not-valid-base64url-ciphertext',
refresh_token: null,
expires_at: new Date(Date.now() + 60_000).toISOString(),
refresh_count: 0,
scope: 'momsdeklaration',
},
error: null,
})
vi.resetModules()
const { getTokens: fresh } = await import('../lib/token-store')
try {
await fresh(fakeSupabase, 'user-1')
expect.fail('expected throw')
} catch (e) {
const err = e as { name: string; code: string; message: string }
expect(err.name).toBe('SkatteverketAuthError')
expect(err.code).toBe('TOKEN_CORRUPTED')
expect(err.message).toMatch(/BankID/)
}
})
})
// Export-ensure: getTokens itself is the only exported symbol we need to
// verify the new behavior. The full storeTokens flow (DELETE+INSERT, SELECT
// failure handling per commit 8865f61) is exercised by integration tests
// against pg-real and not duplicated here.
void getTokens
+164 -8
View File
@@ -9,6 +9,9 @@ import { rutorToMomsuppgift, formatRedovisare, formatRedovisningsperiod } from '
import { calculateVatDeclaration } from '@/lib/reports/vat-declaration'
import { agiSaveDraft, agiValidate, agiGetSubmission, agiDeleteDraft, agiLockPeriod, agiUnlockPeriod, agiGetSubmitted } from './lib/agi-client'
import { buildAGIPayload } from './lib/agi-mappers'
import { syncSkattekonto, SKATTEKONTO_BALANCE_SNAPSHOT_KEY, SKATTEKONTO_LAST_SYNCED_AT_KEY } from './lib/skattekonto-sync'
import { bokforSkattekontoTransaction, SkattekontoBookingError } from './lib/skattekonto-booking'
import type { SkattekontoBalanceSnapshot } from './types'
import type { AGIEmployeeData, AGITotals } from '@/lib/salary/agi/xml-generator'
import type { VatPeriodType } from '@/types'
@@ -56,9 +59,20 @@ export const skatteverketExtension: Extension = {
const appUrl = process.env.NEXT_PUBLIC_APP_URL || 'http://localhost:3000'
const redirectUri = `${appUrl}/api/extensions/ext/skatteverket/callback`
// Optional: where to send the user after the BankID round-trip.
// Allowlisted to internal in-app paths to avoid open-redirect abuse.
const url = new URL(request.url)
const requestedReturn = url.searchParams.get('return_to')
const returnTo =
requestedReturn && requestedReturn.startsWith('/') && !requestedReturn.startsWith('//')
? requestedReturn
: null
// Store state for CSRF validation in callback
await ctx.settings.set('oauth_state', state)
await ctx.settings.set('oauth_redirect_uri', redirectUri)
if (returnTo) await ctx.settings.set('oauth_return_to', returnTo)
else await ctx.settings.set('oauth_return_to', null)
const authorizeUrl = buildAuthorizeUrl(redirectUri, state)
@@ -146,21 +160,40 @@ export const skatteverketExtension: Extension = {
const redirectUri = redirectData?.value ||
`${appUrl}/api/extensions/ext/skatteverket/callback`
// Optional in-app destination set by /authorize?return_to=...
const { data: returnToData } = await supabase
.from('extension_data')
.select('value')
.eq('company_id', companyId)
.eq('extension_id', 'skatteverket')
.eq('key', 'oauth_return_to')
.maybeSingle()
const returnTo = (returnToData?.value as string | null) || null
const successPath = returnTo
? `${returnTo}${returnTo.includes('?') ? '&' : '?'}skv_connected=true`
: `${appUrl}/reports?tab=vat-declaration&skv_connected=true`
const errorPath = (msg: string) =>
returnTo
? `${returnTo}${returnTo.includes('?') ? '&' : '?'}skv_error=${encodeURIComponent(msg)}`
: `${appUrl}/reports?tab=vat-declaration&skv_error=${encodeURIComponent(msg)}`
try {
const tokens = await exchangeCodeForTokens(code, redirectUri)
await storeTokens(supabase, user.id, tokens, companyId)
// Clean up CSRF state
// Clean up CSRF state + the one-shot return_to.
await supabase
.from('extension_data')
.delete()
.eq('company_id', companyId)
.eq('extension_id', 'skatteverket')
.eq('key', 'oauth_state')
.in('key', ['oauth_state', 'oauth_return_to'])
return NextResponse.redirect(
`${appUrl}/reports?tab=vat-declaration&skv_connected=true`
)
const success = returnTo
? `${appUrl}${successPath}`
: successPath
return NextResponse.redirect(success)
} catch (err) {
console.error('[skatteverket] Token exchange failed:', err)
// BankID auth codes expire after 5 minutes. Surface timeouts distinctly
@@ -170,9 +203,8 @@ export const skatteverketExtension: Extension = {
: err instanceof Error
? err.message
: 'Token exchange misslyckades'
return NextResponse.redirect(
`${appUrl}/reports?tab=vat-declaration&skv_error=${encodeURIComponent(message)}`
)
const target = returnTo ? `${appUrl}${errorPath(message)}` : errorPath(message)
return NextResponse.redirect(target)
}
},
},
@@ -925,6 +957,130 @@ export const skatteverketExtension: Extension = {
}
},
},
// ══════════════════════════════════════════════════════════════
// Skattekonto routes (read-only balance + transactions)
// ══════════════════════════════════════════════════════════════
// ── Saldo (cached snapshot) ────────────────────────────────────
// Returns the most recent saldoResponse cached in extension_data.
// The dashboard uses this for repeated renders without hitting SKV.
// Force a refresh by calling POST /skattekonto/sync first.
{
method: 'GET',
path: '/skattekonto/saldo',
handler: async (_request: Request, ctx?: ExtensionContext) => {
if (!ctx) {
return NextResponse.json({ error: 'Extension context required' }, { status: 500 })
}
const snapshot = await ctx.settings.get<SkattekontoBalanceSnapshot>(SKATTEKONTO_BALANCE_SNAPSHOT_KEY)
const lastSyncedAt = await ctx.settings.get<string>(SKATTEKONTO_LAST_SYNCED_AT_KEY)
return NextResponse.json({
data: snapshot?.saldo ?? null,
fetchedAt: snapshot ? new Date(snapshot.fetchedAt).toISOString() : null,
lastSyncedAt: lastSyncedAt ?? null,
})
},
},
// ── Transaktioner (from local table) ───────────────────────────
// Returns booked + upcoming transactions for the active company.
// Optional `from` query filters tidigare on transaktionsdatum >= from.
{
method: 'GET',
path: '/skattekonto/transaktioner',
handler: async (request: Request, ctx?: ExtensionContext) => {
if (!ctx) {
return NextResponse.json({ error: 'Extension context required' }, { status: 500 })
}
const url = new URL(request.url)
const from = url.searchParams.get('from')
let query = ctx.supabase
.from('skattekonto_transactions')
.select('*')
.eq('company_id', ctx.companyId)
.order('transaktionsdatum', { ascending: false })
if (from) query = query.gte('transaktionsdatum', from)
const { data, error } = await query
if (error) {
return NextResponse.json({ error: error.message }, { status: 500 })
}
return NextResponse.json({
data: {
booked: (data ?? []).filter(r => r.status === 'booked'),
upcoming: (data ?? []).filter(r => r.status === 'upcoming'),
},
})
},
},
// ── Manual sync ────────────────────────────────────────────────
// Pulls fresh saldo + transactions from Skatteverket and upserts.
{
method: 'POST',
path: '/skattekonto/sync',
handler: async (_request: Request, ctx?: ExtensionContext) => {
if (!ctx) {
return NextResponse.json({ error: 'Extension context required' }, { status: 500 })
}
try {
const result = await syncSkattekonto(ctx)
return NextResponse.json({ data: result })
} catch (err) {
return handleSkvError(err)
}
},
},
// ── Bokför one row → draft journal entry ──────────────────────
// Creates a DRAFT verifikat in /bookkeeping for the user to review
// and commit. The skattekonto_transactions row is linked via
// journal_entry_id so the UI can show "Bokförd" status.
{
method: 'POST',
path: '/skattekonto/transaktioner/:id/bokfor',
handler: async (request: Request, ctx?: ExtensionContext) => {
if (!ctx) {
return NextResponse.json({ error: 'Extension context required' }, { status: 500 })
}
// Extract :id from the catch-all dispatcher's path-param convention
// (`_id` query string, set in app/api/extensions/ext/[...path]/route.ts).
const url = new URL(request.url)
const id = url.searchParams.get('_id')
if (!id) {
return NextResponse.json({ error: 'Saknar transaktions-id' }, { status: 400 })
}
try {
const entry = await bokforSkattekontoTransaction(
ctx.supabase,
ctx.companyId,
ctx.userId,
id,
)
return NextResponse.json({ data: { entry } })
} catch (err) {
if (err instanceof SkattekontoBookingError) {
const status =
err.code === 'TRANSACTION_NOT_FOUND' ? 404
: err.code === 'ALREADY_BOOKED' ? 409
: err.code === 'PERIOD_LOCKED' ? 423
: err.code === 'NO_COUNTER_ACCOUNT' ? 422
: 400
return NextResponse.json(
{ error: err.message, code: err.code },
{ status },
)
}
return handleSkvError(err)
}
},
},
],
}
@@ -168,7 +168,15 @@ export async function skvRequest(
body: body !== undefined ? JSON.stringify(body) : undefined,
})
// Handle Skatteverket-specific auth errors
// Handle Skatteverket-specific auth/throttle errors uniformly so callers
// can catch a single error type rather than parsing status codes inline.
if (response.status === 401) {
throw new SkatteverketAuthError(
'Sessionen har gått ut. Logga in med BankID igen.',
'SESSION_EXPIRED'
)
}
if (response.status === 403) {
const text = await response.text()
// Behörighet saknas — user is authenticated but not authorized for this company
@@ -185,10 +193,15 @@ export async function skvRequest(
)
}
if (response.status === 401) {
if (response.status === 429) {
// Skatteverket may include a Retry-After header. We surface a generic
// Swedish message — callers can inspect the header on the thrown error
// if they need to schedule a retry. The 4 req/sec local rate limiter
// should normally prevent this; a 429 here implies the per-consumer
// gateway quota was exceeded.
throw new SkatteverketAuthError(
'Sessionen har gått ut. Logga in med BankID igen.',
'SESSION_EXPIRED'
'Skatteverket är överbelastat eller har strypt anropen. Försök igen om en stund.',
'RATE_LIMITED'
)
}
@@ -196,8 +209,19 @@ export async function skvRequest(
}
/**
* Structured error for Skatteverket auth/access issues.
* Structured error for Skatteverket auth/access/throttle issues.
* The `code` field helps the frontend show appropriate UI.
*
* Codes:
* NOT_CONNECTED — no tokens stored; user needs to run BankID flow
* SESSION_EXPIRED — 401 from SKV; refresh exhausted or token rejected
* REFRESH_EXHAUSTED — refresh count hit cap (10) before user re-auth
* BEHORIGHET_SAKNAS — 403 with "Behörighet" body; user not authorized
* for this company at SKV (firmatecknare / ombud)
* ACCESS_DENIED — generic 403
* RATE_LIMITED — 429 from SKV API gateway
* TOKEN_CORRUPTED — stored tokens cannot be decrypted (key rotated
* or row tampered with); user must reconnect
*/
export class SkatteverketAuthError extends Error {
constructor(
@@ -208,6 +232,8 @@ export class SkatteverketAuthError extends Error {
| 'REFRESH_EXHAUSTED'
| 'BEHORIGHET_SAKNAS'
| 'ACCESS_DENIED'
| 'RATE_LIMITED'
| 'TOKEN_CORRUPTED'
) {
super(message)
this.name = 'SkatteverketAuthError'
+1 -1
View File
@@ -17,7 +17,7 @@ import {
*/
const DEFAULT_OAUTH_BASE_URL = 'https://peroauth2.test.skatteverket.se/oauth2/v1/per'
const DEFAULT_SCOPES = 'momsdeklaration inkforetag ska skahmst'
const DEFAULT_SCOPES = 'momsdeklaration inkforetag ska skahmst skattekonto'
function getOAuthBaseUrl(): string {
return process.env.SKATTEVERKET_OAUTH_BASE_URL || DEFAULT_OAUTH_BASE_URL
@@ -0,0 +1,259 @@
import type { SupabaseClient } from '@supabase/supabase-js'
import { createDraftEntry, findFiscalPeriod } from '@/lib/bookkeeping/engine'
import type {
CreateJournalEntryInput,
CreateJournalEntryLineInput,
JournalEntry,
} from '@/types'
/**
* Per-row "Bokför" helper.
*
* Takes a stored skattekonto_transactions row, guesses a counter-account
* from the Swedish description text, and creates a DRAFT journal entry
* via the bookkeeping engine. The user reviews and commits the draft in
* /bookkeeping/[id].
*
* Sign convention (BAS 1630, Skattekonto):
* beloppSkatteverket > 0 (credit on tax account, e.g. payment in)
* → Debit 1630, Credit counter-account
* beloppSkatteverket < 0 (debit on tax account, e.g. F-tax charge)
* → Credit 1630, Debit counter-account
*
* The keyword table mirrors lib/bookkeeping/booking-templates.ts entries
* for skattekonto-related events, and is intentionally narrow — when no
* keyword matches, throw SkattekontoBookingError and let the UI route the
* user to a manual entry rather than fabricate a counter-account.
*/
const SKATTEKONTO_ACCOUNT = '1630'
export type EntityType = 'enskild_firma' | 'aktiebolag'
interface CounterAccountRule {
/** Lower-cased substrings; ANY matching wins. */
match: string[]
/** Counter-account number, possibly entity-type dependent. */
account: string | { aktiebolag: string; enskild_firma: string }
/** Optional human-readable label for the line description. */
label?: string
}
const COUNTER_ACCOUNT_RULES: CounterAccountRule[] = [
// Cash flows in/out
{
match: ['inbetalning bokförd', 'inbetalning', 'överföring från bank'],
account: '1930',
label: 'Inbetalning till skattekonto',
},
{
match: ['utbetalning', 'återbetalning'],
account: '1930',
label: 'Utbetalning från skattekonto',
},
// Preliminary income tax — different liability accounts for AB vs EF
{
match: ['debiterad preliminärskatt', 'preliminärskatt', 'f-skatt', 'fskatt'],
account: { aktiebolag: '2510', enskild_firma: '2012' },
label: 'Preliminär skatt',
},
// Employer payroll taxes
{
match: ['arbetsgivaravgift', 'sociala avgifter', 'agi'],
account: '2731',
label: 'Arbetsgivaravgifter',
},
{
match: ['avdragen skatt', 'personalskatt', 'a-skatt'],
account: '2710',
label: 'Avdragen skatt anställda',
},
// VAT — settlement account
{
match: ['mervärdesskatt', 'moms', 'momsdeklaration'],
account: '2650',
label: 'Redovisningskonto för moms',
},
// Interest — Skatteverket charges/credits interest on the account
{
match: ['kostnadsränta'],
account: '8423',
label: 'Kostnadsränta skattekonto',
},
{
match: ['intäktsränta'],
account: '8313',
label: 'Intäktsränta skattekonto',
},
]
export class SkattekontoBookingError extends Error {
constructor(
message: string,
public readonly code:
| 'NO_COUNTER_ACCOUNT'
| 'NO_FISCAL_PERIOD'
| 'PERIOD_LOCKED'
| 'ALREADY_BOOKED'
| 'TRANSACTION_NOT_FOUND',
) {
super(message)
this.name = 'SkattekontoBookingError'
}
}
interface CounterAccountMatch {
account: string
label: string
}
/**
* Find the counter-account for a Skatteverket transaktionstext.
* Returns null if no rule matches. Public so tests can exercise it.
*/
export function guessCounterAccount(
transaktionstext: string,
entityType: EntityType,
): CounterAccountMatch | null {
const normalized = transaktionstext.toLowerCase()
for (const rule of COUNTER_ACCOUNT_RULES) {
if (rule.match.some(needle => normalized.includes(needle))) {
const account =
typeof rule.account === 'string' ? rule.account : rule.account[entityType]
return {
account,
label: rule.label ?? transaktionstext,
}
}
}
return null
}
/**
* Create a draft journal entry for one skattekonto_transactions row.
*
* Throws SkattekontoBookingError on:
* - already-booked rows (journal_entry_id present)
* - missing/locked fiscal period for the transaktionsdatum
* - no keyword match → user must categorize manually
*
* Returns the created JournalEntry. Caller is responsible for writing
* `journal_entry_id` back onto the skattekonto_transactions row.
*/
export async function bokforSkattekontoTransaction(
supabase: SupabaseClient,
companyId: string,
userId: string,
transactionId: string,
): Promise<JournalEntry> {
// 1. Load the transaction
const { data: tx, error: txError } = await supabase
.from('skattekonto_transactions')
.select('*')
.eq('id', transactionId)
.eq('company_id', companyId)
.single()
if (txError || !tx) {
throw new SkattekontoBookingError(
'Skattekonto-transaktionen hittades inte.',
'TRANSACTION_NOT_FOUND',
)
}
if (tx.journal_entry_id) {
throw new SkattekontoBookingError(
'Transaktionen är redan bokförd.',
'ALREADY_BOOKED',
)
}
// 2. Get entity_type for AB/EF-specific accounts
const { data: settings } = await supabase
.from('company_settings')
.select('entity_type')
.eq('company_id', companyId)
.single()
const entityType: EntityType =
(settings?.entity_type as EntityType) ?? 'aktiebolag'
// 3. Guess counter-account
const guess = guessCounterAccount(tx.transaktionstext, entityType)
if (!guess) {
throw new SkattekontoBookingError(
`Vi kunde inte gissa motkontot för "${tx.transaktionstext}". Skapa verifikatet manuellt.`,
'NO_COUNTER_ACCOUNT',
)
}
// 4. Resolve fiscal period for entry date
const fiscalPeriodId = await findFiscalPeriod(
supabase,
companyId,
tx.transaktionsdatum,
)
if (!fiscalPeriodId) {
throw new SkattekontoBookingError(
`Datumet ${tx.transaktionsdatum} ligger i en låst eller saknad räkenskapsperiod. ` +
'Lås upp perioden eller hoppa över raden.',
'PERIOD_LOCKED',
)
}
// 5. Build lines based on sign convention
const amount = Math.abs(Number(tx.belopp_skatteverket))
const isCreditToSkattekonto = Number(tx.belopp_skatteverket) > 0
const lines: CreateJournalEntryLineInput[] = isCreditToSkattekonto
? [
{
account_number: SKATTEKONTO_ACCOUNT,
debit_amount: amount,
credit_amount: 0,
line_description: tx.transaktionstext,
},
{
account_number: guess.account,
debit_amount: 0,
credit_amount: amount,
line_description: guess.label,
},
]
: [
{
account_number: guess.account,
debit_amount: amount,
credit_amount: 0,
line_description: guess.label,
},
{
account_number: SKATTEKONTO_ACCOUNT,
debit_amount: 0,
credit_amount: amount,
line_description: tx.transaktionstext,
},
]
const input: CreateJournalEntryInput = {
fiscal_period_id: fiscalPeriodId,
entry_date: tx.transaktionsdatum,
description: `Skattekonto: ${tx.transaktionstext}`,
source_type: 'system',
source_id: tx.id,
notes: `Genererad från skattekonto-synk. Skatteverket-id: ${tx.transaktionsidentitet ?? '–'}`,
lines,
created_via: 'manual',
}
const entry = await createDraftEntry(supabase, companyId, userId, input)
// Link the row back so the dashboard can show "Bokförd" status.
await supabase
.from('skattekonto_transactions')
.update({ journal_entry_id: entry.id })
.eq('id', tx.id)
.eq('company_id', companyId)
return entry
}
@@ -0,0 +1,157 @@
import type { SupabaseClient } from '@supabase/supabase-js'
import { skvRequest } from './api-client'
import type {
SkatteverketSaldoResponse,
SkatteverketTransaktionerResponse,
SkatteverketFel,
} from '../types'
/**
* Skatteverket Skattekonto API v2.1.0 client.
*
* Spec: https://api.skatteverket.se/beskattning/skattekonto/v2
* Test: https://api.test.skatteverket.se/beskattning/skattekonto/v2
*
* The personal OAuth (BankID) tokens already used for momsdeklaration also
* grant access to skattekonto when the OAuth scope includes `skattekonto`.
* skvRequest() handles the gateway headers, rate limiting, and refresh.
*/
const DEFAULT_SKATTEKONTO_BASE_URL =
'https://api.test.skatteverket.se/beskattning/skattekonto/v2'
function getBaseUrl(): string {
return (
process.env.SKATTEVERKET_SKATTEKONTO_API_BASE_URL ||
DEFAULT_SKATTEKONTO_BASE_URL
)
}
/**
* Map Skatteverket error codes (felkod 1–5) to Swedish user messages.
*
* Codes per dev_docs/skattekonto(2.1.0)/examples/felkod_*.json.
*/
function mapFelkodToMessage(fel: SkatteverketFel): string {
switch (fel.felkod) {
case 1:
return 'Felaktigt organisationsnummer.'
case 2:
return 'Felaktigt datum.'
case 3:
return 'Inget skattekonto är registrerat hos Skatteverket.'
case 4:
return 'Internt fel hos Skatteverket. Försök igen om en stund.'
case 5:
return 'Skattekontot är stängt.'
default:
return fel.felmeddelande || `Skatteverket-fel ${fel.felkod}`
}
}
/**
* Throws a typed error with a Swedish message when Skatteverket returns
* a non-200 response. The skvRequest() helper has already mapped 401/403/429
* to SkatteverketAuthError, so here we only handle 400/404/500/503 and the
* felkod envelope returned in the body.
*/
async function handleErrorResponse(response: Response): Promise<never> {
let fel: SkatteverketFel | null = null
try {
fel = (await response.json()) as SkatteverketFel
} catch {
// body wasn't JSON — fall through to generic message
}
if (fel && typeof fel.felkod === 'number') {
throw new SkatteverketSkattekontoError(
mapFelkodToMessage(fel),
fel.felkod,
response.status,
)
}
throw new SkatteverketSkattekontoError(
`Skatteverket svarade med ${response.status}`,
null,
response.status,
)
}
/**
* Structured error for skattekonto-specific Skatteverket failures.
* Distinct from SkatteverketAuthError (which signals auth/access/throttle).
*/
export class SkatteverketSkattekontoError extends Error {
constructor(
message: string,
public readonly felkod: number | null,
public readonly httpStatus: number,
) {
super(message)
this.name = 'SkatteverketSkattekontoError'
}
}
/**
* GET /skattekonton/{omfragad}/saldo
*
* @param omfragad 10/12-digit org/personnummer (formatRedovisare format)
* @param datum Optional ISO date (YYYY-MM-DD); fetch balance as of date
*/
export async function getSaldo(
supabase: SupabaseClient,
userId: string,
omfragad: string,
datum?: string,
): Promise<SkatteverketSaldoResponse> {
const qs = datum ? `?datum=${encodeURIComponent(datum)}` : ''
const response = await skvRequest(
supabase,
userId,
'GET',
`/skattekonton/${omfragad}/saldo${qs}`,
undefined,
{ baseUrl: getBaseUrl() },
)
if (!response.ok) {
await handleErrorResponse(response)
}
return (await response.json()) as SkatteverketSaldoResponse
}
/**
* GET /skattekonton/{omfragad}/transaktioner
*
* @param omfragad 10/12-digit org/personnummer
* @param datumFrom Optional ISO date (YYYY-MM-DD). Defaults at SKV to
* 555 days back; max lookback is 915 days.
*/
export async function getTransaktioner(
supabase: SupabaseClient,
userId: string,
omfragad: string,
datumFrom?: string,
): Promise<SkatteverketTransaktionerResponse> {
const qs = datumFrom ? `?datumFrom=${encodeURIComponent(datumFrom)}` : ''
const response = await skvRequest(
supabase,
userId,
'GET',
`/skattekonton/${omfragad}/transaktioner${qs}`,
undefined,
{ baseUrl: getBaseUrl() },
)
if (!response.ok) {
await handleErrorResponse(response)
}
const data = (await response.json()) as Partial<SkatteverketTransaktionerResponse>
return {
tidigareTransaktioner: data.tidigareTransaktioner ?? [],
kommandeTransaktioner: data.kommandeTransaktioner ?? [],
}
}
@@ -0,0 +1,276 @@
import crypto from 'crypto'
import type { SupabaseClient } from '@supabase/supabase-js'
import type { ExtensionContext } from '@/lib/extensions/types'
import { eventBus } from '@/lib/events/bus'
import { createLogger } from '@/lib/logger'
import { formatRedovisare } from '@/lib/skatteverket/format'
import { getSaldo, getTransaktioner } from './skattekonto-client'
import { SkatteverketAuthError } from './api-client'
import type {
SkatteverketBookedTransaction,
SkatteverketUpcomingTransaction,
SkatteverketSaldoResponse,
SkattekontoBalanceSnapshot,
StoredSkattekontoTransaction,
} from '../types'
const log = createLogger('skattekonto-sync')
const BALANCE_SNAPSHOT_KEY = 'skattekonto_balance_snapshot'
const LAST_SYNCED_AT_KEY = 'skattekonto_last_synced_at'
export interface SkattekontoSyncResult {
/** Number of new or status-promoted booked rows */
booked: number
/** Number of new or updated upcoming rows */
upcoming: number
/** Saldo at end of sync (mirrors snapshot) */
saldoSkatteverket: number
saldoKronofogden: number
/** Sync timestamp */
syncedAt: string
}
/**
* Compute the dedup key for a transaction.
*
* - When `transaktionsidentitet` is present (always on tidigare, sometimes
* on kommande), use it directly. It's stable across syncs.
* - Otherwise compute a sha256 hex over (date|amount|text) — stable enough
* for kommande, which graduate to tidigare with the same content.
*
* The point of this function is reproducibility: the same logical
* transaction must always produce the same dedup_key.
*/
export function computeDedupKey(tx: {
transaktionsidentitet?: number | null
transaktionsdatum: string
beloppSkatteverket: number
transaktionstext: string
}): string {
if (tx.transaktionsidentitet != null) {
return `id:${tx.transaktionsidentitet}`
}
const material = `${tx.transaktionsdatum}|${tx.beloppSkatteverket}|${tx.transaktionstext}`
return `h:${crypto.createHash('sha256').update(material).digest('hex')}`
}
/**
* Resolve the org/personnummer to send to Skatteverket as `omfragad`.
* Reads from company_settings — same source the existing momsdeklaration
* flow uses.
*/
async function resolveOmfragad(
supabase: SupabaseClient,
companyId: string,
): Promise<string> {
const { data: settings } = await supabase
.from('company_settings')
.select('org_number, entity_type')
.eq('company_id', companyId)
.single()
if (!settings?.org_number) {
throw new Error('Organisationsnummer saknas i företagsinställningar')
}
return formatRedovisare(settings.org_number, settings.entity_type)
}
/**
* Build the row to insert/upsert into skattekonto_transactions.
*/
function bookedToRow(
companyId: string,
tx: SkatteverketBookedTransaction,
): Omit<StoredSkattekontoTransaction, 'id' | 'imported_at' | 'updated_at' | 'journal_entry_id'> {
return {
company_id: companyId,
transaktionsidentitet: tx.transaktionsidentitet,
dedup_key: computeDedupKey(tx),
transaktionsdatum: tx.transaktionsdatum,
forfallodatum: null,
ranteberakningsdatum: tx.ranteberakningsdatum,
transaktionstext: tx.transaktionstext,
belopp_skatteverket: tx.beloppSkatteverket,
belopp_kronofogden: tx.beloppKronofogden,
status: 'booked',
}
}
function upcomingToRow(
companyId: string,
tx: SkatteverketUpcomingTransaction,
): Omit<StoredSkattekontoTransaction, 'id' | 'imported_at' | 'updated_at' | 'journal_entry_id'> {
return {
company_id: companyId,
transaktionsidentitet: tx.transaktionsidentitet ?? null,
dedup_key: computeDedupKey(tx),
transaktionsdatum: tx.transaktionsdatum,
forfallodatum: tx.forfallodatum,
ranteberakningsdatum: tx.ranteberakningsdatum,
transaktionstext: tx.transaktionstext,
belopp_skatteverket: tx.beloppSkatteverket,
belopp_kronofogden: tx.beloppKronofogden,
status: 'upcoming',
}
}
/**
* Sync skattekonto data for the active company in `ctx`.
*
* Steps:
* 1. Resolve omfragad from company_settings.
* 2. Fetch saldo + transaktioner in parallel (rate-limited).
* 3. Upsert rows by (company_id, dedup_key). When a kommande row graduates
* to tidigare it's updated in place — same dedup_key, status flips,
* transaktionsidentitet populated.
* 4. Cache the saldo response in extension_data with a fetched-at timestamp.
* 5. Emit skattekonto.synced and (when applicable) other events.
*/
export async function syncSkattekonto(
ctx: ExtensionContext,
): Promise<SkattekontoSyncResult> {
const omfragad = await resolveOmfragad(ctx.supabase, ctx.companyId)
let saldo: SkatteverketSaldoResponse
let transaktioner: Awaited<ReturnType<typeof getTransaktioner>>
try {
;[saldo, transaktioner] = await Promise.all([
getSaldo(ctx.supabase, ctx.userId, omfragad),
getTransaktioner(ctx.supabase, ctx.userId, omfragad),
])
} catch (err) {
if (err instanceof SkatteverketAuthError) {
if (
err.code === 'REFRESH_EXHAUSTED' ||
err.code === 'SESSION_EXPIRED' ||
err.code === 'TOKEN_CORRUPTED'
) {
await eventBus.emit({
type: 'skattekonto.connection.expired',
payload: {
reason: err.code,
userId: ctx.userId,
companyId: ctx.companyId,
},
})
}
}
throw err
}
const previousSnapshot = await ctx.settings.get<SkattekontoBalanceSnapshot>(
BALANCE_SNAPSHOT_KEY,
)
const previousBalance = previousSnapshot?.saldo.saldoSkatteverket ?? null
const bookedRows = transaktioner.tidigareTransaktioner.map(tx =>
bookedToRow(ctx.companyId, tx),
)
const upcomingRows = transaktioner.kommandeTransaktioner.map(tx =>
upcomingToRow(ctx.companyId, tx),
)
// Upsert in two steps to keep the conflict target consistent. We rely on
// the (company_id, dedup_key) unique constraint defined in the migration.
const allRows = [...bookedRows, ...upcomingRows]
// Find which dedup_keys are NEW (not yet in the table) so we can fire
// the `skattekonto.transaction.upcoming` event only for first-appearance
// upcoming rows.
const dedupKeys = allRows.map(r => r.dedup_key)
const { data: existingRows } = dedupKeys.length
? await ctx.supabase
.from('skattekonto_transactions')
.select('dedup_key, status')
.eq('company_id', ctx.companyId)
.in('dedup_key', dedupKeys)
: { data: [] }
const existingMap = new Map<string, { status: 'booked' | 'upcoming' }>()
for (const row of existingRows ?? []) {
existingMap.set(row.dedup_key as string, {
status: row.status as 'booked' | 'upcoming',
})
}
if (allRows.length > 0) {
const { error } = await ctx.supabase
.from('skattekonto_transactions')
.upsert(allRows, {
onConflict: 'company_id,dedup_key',
// Don't return rows — we already know what we wrote.
ignoreDuplicates: false,
})
if (error) {
log.error('upsert failed', { companyId: ctx.companyId, message: error.message })
throw new Error(`Kunde inte spara skattekonto-transaktioner: ${error.message}`)
}
}
// Cache balance snapshot.
const snapshot: SkattekontoBalanceSnapshot = {
saldo,
fetchedAt: Date.now(),
}
await ctx.settings.set(BALANCE_SNAPSHOT_KEY, snapshot)
await ctx.settings.set(LAST_SYNCED_AT_KEY, new Date().toISOString())
// Emit events.
await ctx.emit({
type: 'skattekonto.synced',
payload: {
booked: bookedRows.length,
upcoming: upcomingRows.length,
balanceSkv: saldo.saldoSkatteverket,
balanceKfm: saldo.saldoKronofogden,
userId: ctx.userId,
companyId: ctx.companyId,
},
})
// Sign flip → fire balance.changed.
if (
previousBalance !== null &&
Math.sign(previousBalance) !== Math.sign(saldo.saldoSkatteverket)
) {
await ctx.emit({
type: 'skattekonto.balance.changed',
payload: {
previousBalance,
currentBalance: saldo.saldoSkatteverket,
userId: ctx.userId,
companyId: ctx.companyId,
},
})
}
// First-appearance upcoming transactions.
for (const tx of transaktioner.kommandeTransaktioner) {
const key = computeDedupKey(tx)
if (existingMap.has(key)) continue
await ctx.emit({
type: 'skattekonto.transaction.upcoming',
payload: {
transaktionsdatum: tx.transaktionsdatum,
forfallodatum: tx.forfallodatum,
transaktionstext: tx.transaktionstext,
beloppSkatteverket: tx.beloppSkatteverket,
userId: ctx.userId,
companyId: ctx.companyId,
},
})
}
return {
booked: bookedRows.length,
upcoming: upcomingRows.length,
saldoSkatteverket: saldo.saldoSkatteverket,
saldoKronofogden: saldo.saldoKronofogden,
syncedAt: new Date().toISOString(),
}
}
export const SKATTEKONTO_BALANCE_SNAPSHOT_KEY = BALANCE_SNAPSHOT_KEY
export const SKATTEKONTO_LAST_SYNCED_AT_KEY = LAST_SYNCED_AT_KEY
@@ -1,6 +1,10 @@
import crypto from 'crypto'
import { createClient, type SupabaseClient } from '@supabase/supabase-js'
import { createLogger } from '@/lib/logger'
import type { SkatteverketTokens } from '../types'
import { SkatteverketAuthError } from './api-client'
const log = createLogger('skatteverket-token-store')
/**
* Encrypted token storage for Skatteverket OAuth2 tokens.
@@ -138,6 +142,12 @@ export async function getTokens(
if (error || !data) return null
// Distinguish three states:
// 1. No row → caller treats as NOT_CONNECTED (return null above)
// 2. Decryption error → log + throw TOKEN_CORRUPTED so the caller can
// tell the user to reconnect. Previously returned null silently
// which masked the real problem (key rotation, tampering, or a
// schema-level bug) as "not connected".
try {
return {
access_token: decrypt(data.access_token),
@@ -146,9 +156,15 @@ export async function getTokens(
refresh_count: data.refresh_count ?? 0,
scope: data.scope,
}
} catch {
// Decryption failed — key may have rotated, tokens are invalid
return null
} catch (err) {
log.error('decryption failed for stored tokens', {
userId,
error: err instanceof Error ? err.message : String(err),
})
throw new SkatteverketAuthError(
'Tokens kunde inte läsas. Anslut igen med BankID.',
'TOKEN_CORRUPTED'
)
}
}
+97
View File
@@ -169,3 +169,100 @@ export interface SkatteverketSubmission {
created_at: string
updated_at: string
}
// ── Skattekonto (tax account) types ────────────────────────────
//
// Field names match Skatteverket's Skattekonto API v2.1.0 JSON schema.
// Spec: dev_docs/skattekonto(2.1.0)/skattekonto-extern.raml
// Amount fields are in SEK (whole or decimal); negative = debt to SKV.
/** Response from GET /skattekonton/{omfragad}/saldo */
export interface SkatteverketSaldoResponse {
/** Next reconciliation date (YYYY-MM-DD) */
nastaAvstamningsdatum: string
/** Last update timestamp (ISO 8601) */
senastUppdaterad: string
/** Free-text info messages (max 200 chars each) */
informationstext: string[]
/** Current balance at Skatteverket (negative = debt) */
saldoSkatteverket: number
/** Balance moved to Kronofogden (negative = enforcement debt) */
saldoKronofogden: number
/** Preliminary interest accrued at Skatteverket */
rantaSkatteverket: number
/** Preliminary interest accrued at Kronofogden */
rantaKronofogden: number
/** OCR reference for paying the balance */
ocrNummer: string
}
/** Booked transaction (tidigareTransaktioner) */
export interface SkatteverketBookedTransaction {
/** Stable identity from Skatteverket — primary dedup key */
transaktionsidentitet: number
/** Booking date (YYYY-MM-DD) */
transaktionsdatum: string
/** Interest calculation date (YYYY-MM-DD) */
ranteberakningsdatum: string | null
/** Description (e.g. "Inbetalning bokförd 190412") */
transaktionstext: string
/** Amount at Skatteverket (positive = credit, negative = debit) */
beloppSkatteverket: number
/** Amount moved to Kronofogden (rare) */
beloppKronofogden: number | null
}
/** Future / scheduled transaction (kommandeTransaktioner) */
export interface SkatteverketUpcomingTransaction {
/** Posting date (YYYY-MM-DD) */
transaktionsdatum: string
/** Due date for payment (YYYY-MM-DD) */
forfallodatum: string
/** Interest calculation date (YYYY-MM-DD) */
ranteberakningsdatum: string | null
/** Description */
transaktionstext: string
/** Amount at Skatteverket */
beloppSkatteverket: number
/** Amount at Kronofogden */
beloppKronofogden: number | null
/** Often null on kommande — fall back to dedup_key */
transaktionsidentitet: number | null
}
/** Response from GET /skattekonton/{omfragad}/transaktioner */
export interface SkatteverketTransaktionerResponse {
tidigareTransaktioner: SkatteverketBookedTransaction[]
kommandeTransaktioner: SkatteverketUpcomingTransaction[]
}
/** Skatteverket error envelope (felkod 1–5) */
export interface SkatteverketFel {
felkod: number
felmeddelande: string
}
/** Row shape for the skattekonto_transactions table (DB → app) */
export interface StoredSkattekontoTransaction {
id: string
company_id: string
transaktionsidentitet: number | null
dedup_key: string
transaktionsdatum: string
forfallodatum: string | null
ranteberakningsdatum: string | null
transaktionstext: string
belopp_skatteverket: number
belopp_kronofogden: number | null
status: 'booked' | 'upcoming'
journal_entry_id: string | null
imported_at: string
updated_at: string
}
/** Cached snapshot stored in extension_data under key skattekonto_balance_snapshot */
export interface SkattekontoBalanceSnapshot {
saldo: SkatteverketSaldoResponse
/** Unix ms when this snapshot was fetched from Skatteverket */
fetchedAt: number
}
+33
View File
@@ -774,6 +774,39 @@ export const CreateSalaryLineItemSchema = z.object({
export const UpdateSalaryLineItemSchema = CreateSalaryLineItemSchema.partial().omit({ salary_run_employee_id: true })
// ── Absence (frånvaro) per-day records ──────────────────────────────
//
// Drives sjuklönelagen calculations (karensavdrag boundary, återinsjuknande
// 5-day merge, högriskskydd 12-month cap, day 14/15 FK transition) and AGI
// 2025+ <Frånvarouppgift> per-event reporting. The salary calculator derives
// line items from these rows; users do not enter absence as line items.
export const AbsenceTypeSchema = z.enum([
'sick',
'vab',
'parental',
'pregnancy',
'care_relative',
'study',
'other_leave',
])
export const UpsertAbsenceDaySchema = z.object({
absence_date: isoDate,
absence_type: AbsenceTypeSchema,
hours: z.number().positive().max(24).default(8),
notes: z.string().max(2000).optional(),
salary_run_employee_id: uuid.optional(),
})
export const AbsenceRangeQuerySchema = z.object({
from: isoDate,
to: isoDate,
}).refine((data) => data.from <= data.to, {
message: '`from` måste vara före eller lika med `to`',
path: ['from'],
})
// ============================================================
// AI agent flow schemas
// ============================================================
+5
View File
@@ -86,6 +86,11 @@ export type CoreEvent =
| { type: 'salary_run.booked'; payload: { salaryRunId: string; entryIds: string[]; userId: string; companyId: string } }
| { type: 'agi.generated'; payload: { agiId: string; periodYear: number; periodMonth: number; userId: string; companyId: string } }
| { type: 'agi.submitted'; payload: { salaryRunId: string; periodYear: number; periodMonth: number; userId: string; companyId: string } }
// Skatteverket — Skattekonto sync
| { type: 'skattekonto.synced'; payload: { booked: number; upcoming: number; balanceSkv: number; balanceKfm: number; userId: string; companyId: string } }
| { type: 'skattekonto.balance.changed'; payload: { previousBalance: number; currentBalance: number; userId: string; companyId: string } }
| { type: 'skattekonto.transaction.upcoming'; payload: { transaktionsdatum: string; forfallodatum: string; transaktionstext: string; beloppSkatteverket: number; userId: string; companyId: string } }
| { type: 'skattekonto.connection.expired'; payload: { reason: 'REFRESH_EXHAUSTED' | 'SESSION_EXPIRED' | 'TOKEN_CORRUPTED'; userId: string; companyId: string } }
// Company & account lifecycle
| { type: 'company.deleted'; payload: { companyId: string; userId: string; archivedAt: string } }
| { type: 'account.deleted'; payload: { userId: string; deletedAt: string } }
+162
View File
@@ -300,3 +300,165 @@ describe('buildIndividuppgifterSnapshot', () => {
expect(snapshot[0]).toHaveProperty('ruta020', 40000)
})
})
// ─── Frånvarouppgift (FK820-827) ────────────────────────────────────
describe('generateAGIXml — Frånvarouppgift', () => {
const employeesWithAbsence: AGIEmployeeData[] = [
{
personnummer: 'emp1_encrypted',
specificationNumber: 1,
grossSalary: 40000,
taxWithheld: 12000,
avgifterBasis: 40000,
absenceEvents: [
{ date: '2026-04-15', type: 'vab', hours: 8 },
{ date: '2026-04-16', type: 'vab', hours: 4 },
],
},
{
personnummer: 'emp2_encrypted',
specificationNumber: 2,
grossSalary: 35000,
taxWithheld: 10500,
avgifterBasis: 35000,
absenceEvents: [
{ date: '2026-04-20', type: 'parental', hours: 8 },
],
},
]
it('emits a <gem:Franvarouppgift> per absence event', () => {
const xml = generateAGIXml(company, employeesWithAbsence, totals)
const matches = xml.match(/<gem:Franvarouppgift>/g) ?? []
expect(matches).toHaveLength(3)
})
it('emits TILLFALLIG_FORALDRAPENNING with FranvaroTimmarTFP for VAB', () => {
const xml = generateAGIXml(company, employeesWithAbsence, totals)
expect(xml).toContain('<gem:FranvaroTyp faltkod="823">TILLFALLIG_FORALDRAPENNING</gem:FranvaroTyp>')
expect(xml).toContain('<gem:FranvaroTimmarTFP faltkod="825">8</gem:FranvaroTimmarTFP>')
expect(xml).toContain('<gem:FranvaroTimmarTFP faltkod="825">4</gem:FranvaroTimmarTFP>')
})
it('emits FORALDRAPENNING with FranvaroTimmarFP for parental leave', () => {
const xml = generateAGIXml(company, employeesWithAbsence, totals)
expect(xml).toContain('<gem:FranvaroTyp faltkod="823">FORALDRAPENNING</gem:FranvaroTyp>')
expect(xml).toContain('<gem:FranvaroTimmarFP faltkod="827">8</gem:FranvaroTimmarFP>')
})
it('does NOT emit TFP hour-fields for parental events', () => {
const xml = generateAGIXml(
company,
[{
...employeesWithAbsence[1],
absenceEvents: [{ date: '2026-04-20', type: 'parental', hours: 8 }],
}],
totals,
)
expect(xml).not.toMatch(/FranvaroTimmarTFP|FranvaroProcentTFP/)
})
it('uses 1-based stable specifikationsnummer per employee, ordered by date', () => {
const xml = generateAGIXml(company, employeesWithAbsence, totals)
// emp1 has two events on 2026-04-15 and 2026-04-16
expect(xml).toContain('<gem:FranvaroSpecifikationsnummer faltkod="822">1</gem:FranvaroSpecifikationsnummer>')
expect(xml).toContain('<gem:FranvaroSpecifikationsnummer faltkod="822">2</gem:FranvaroSpecifikationsnummer>')
})
it('formats fractional hours with up to 2 decimals', () => {
const xml = generateAGIXml(
company,
[{
...employeesWithAbsence[0],
absenceEvents: [{ date: '2026-04-15', type: 'vab', hours: 4.5 }],
}],
totals,
)
expect(xml).toContain('<gem:FranvaroTimmarTFP faltkod="825">4.5</gem:FranvaroTimmarTFP>')
})
it('clamps hours into the spec range (0.01–24.00)', () => {
const xml = generateAGIXml(
company,
[{
...employeesWithAbsence[0],
absenceEvents: [{ date: '2026-04-15', type: 'vab', hours: 50 }],
}],
totals,
)
expect(xml).toContain('<gem:FranvaroTimmarTFP faltkod="825">24</gem:FranvaroTimmarTFP>')
})
it('skips Frånvarouppgift entirely for periods before 202501', () => {
const xml = generateAGIXml(
{ ...company, periodYear: 2024, periodMonth: 12 },
employeesWithAbsence,
totals,
)
expect(xml).not.toContain('Franvarouppgift')
})
it('emits Frånvarouppgift for the boundary period 202501', () => {
const xml = generateAGIXml(
{ ...company, periodYear: 2025, periodMonth: 1 },
[{
...employeesWithAbsence[0],
absenceEvents: [{ date: '2025-01-15', type: 'vab', hours: 8 }],
}],
totals,
)
expect(xml).toContain('<gem:Franvarouppgift>')
expect(xml).toContain('<gem:FranvaroDatum faltkod="821">2025-01-15</gem:FranvaroDatum>')
})
it('places Frånvarouppgift after IU Blanketts and before </Skatteverket>', () => {
const xml = generateAGIXml(company, employeesWithAbsence, totals)
const lastBlankettClose = xml.lastIndexOf('</gem:Blankett>')
const firstFranvaro = xml.indexOf('<gem:Franvarouppgift>')
const closeRoot = xml.indexOf('</Skatteverket>')
expect(firstFranvaro).toBeGreaterThan(lastBlankettClose)
expect(closeRoot).toBeGreaterThan(firstFranvaro)
})
it('emits FK820/824/826 absence-removal and percent fields not at all (gnubok always sends timmar)', () => {
const xml = generateAGIXml(company, employeesWithAbsence, totals)
expect(xml).not.toContain('faltkod="820"')
expect(xml).not.toContain('faltkod="824"')
expect(xml).not.toContain('faltkod="826"')
})
it('preserves date order across employees with mixed types', () => {
const xml = generateAGIXml(company, employeesWithAbsence, totals)
const idx15 = xml.indexOf('2026-04-15')
const idx16 = xml.indexOf('2026-04-16')
const idx20 = xml.indexOf('2026-04-20')
expect(idx15).toBeLessThan(idx16)
expect(idx16).toBeLessThan(idx20)
})
it('emits required fields per Frånvarouppgift (AgRegistreradId, RedovisningsPeriod, FranvaroDatum, BetalningsmottagarId, Specifikationsnummer, FranvaroChoice)', () => {
const xml = generateAGIXml(
company,
[{
...employeesWithAbsence[0],
absenceEvents: [{ date: '2026-04-15', type: 'vab', hours: 8 }],
}],
totals,
)
// Single Frånvarouppgift block
const block = xml.slice(xml.indexOf('<gem:Franvarouppgift>'), xml.indexOf('</gem:Franvarouppgift>'))
expect(block).toContain('faltkod="201"') // AgRegistreradId
expect(block).toContain('faltkod="006"') // RedovisningsPeriod
expect(block).toContain('faltkod="821"') // FranvaroDatum
expect(block).toContain('faltkod="215"') // BetalningsmottagarId
expect(block).toContain('faltkod="822"') // FranvaroSpecifikationsnummer
expect(block).toContain('<gem:FranvaroChoice>')
expect(block).toContain('faltkod="823"') // FranvaroTyp inside choice
})
it('omits the section entirely when no employee has absenceEvents', () => {
const xml = generateAGIXml(company, employees, totals)
expect(xml).not.toContain('Franvarouppgift')
})
})
@@ -0,0 +1,248 @@
import { describe, it, expect } from 'vitest'
import {
deriveAbsenceLineItems,
buildSjukloneperioder,
type AbsenceDay,
type DeriveInput,
} from '../derive-absence-line-items'
import type { PayrollConfig } from '../payroll-config'
const config: PayrollConfig = {
configYear: 2026,
avgifterTotal: 0.3142,
avgifterAlderspension: 0.1021,
avgifterSjukforsakring: 0.0355,
avgifterForaldraforsakring: 0.02,
avgifterEfterlevandepension: 0.003,
avgifterArbetsmarknad: 0.0264,
avgifterArbetsskada: 0.001,
avgifterAllmanLoneavgift: 0.1262,
avgifterReduced65plus: 0.1021,
avgifterYouthRate: 0.2081,
avgifterYouthSalaryCap: 25000,
avgifterVaxaStodRate: 0.1021,
avgifterVaxaStodCap: 35000,
avgifterMinimumAnnual: 1000,
egenavgifterTotal: 0.2897,
slpRate: 0.2426,
prisbasbelopp: 59200,
inkomstbasbelopp: 83400,
maxPgi: 625500,
sgiCeiling: 592000,
statligSkattBrytpunkt: 660400,
traktamenteHeldag: 300,
traktamenteHalvdag: 150,
traktamenteNatt: 150,
milersattningEgenBil: 25,
milersattningFormansbilFossil: 12,
milersattningFormansbilEl: 9.5,
kostformanHeldag: 310,
kostformanLunch: 124,
kostformanFrukost: 62,
friskvardCap: 5000,
bilformanSlr: 0.0255,
sjuklonRate: 0.8,
karensavdragFactor: 0.2,
maxKarensavdragPerYear: 10,
reducedAvgiftAge: 67,
}
const days = (entries: Array<[string, AbsenceDay['absence_type']]>): AbsenceDay[] =>
entries.map(([d, t]) => ({ absence_date: d, absence_type: t, hours: 8 }))
const baseInput = (over: Partial<DeriveInput> = {}): DeriveInput => ({
monthlySalary: 30000,
payrollConfig: config,
periodDays: [],
lookbackSickDates: [],
vabDaysYtd: 0,
parentalDaysPregnancyYtd: 0,
...over,
})
describe('buildSjukloneperioder', () => {
it('treats consecutive days as one period', () => {
const segs = buildSjukloneperioder(['2026-04-06', '2026-04-07', '2026-04-08'])
expect(segs).toHaveLength(1)
expect(segs[0].sickDayCount).toBe(3)
expect(segs[0].startDate).toBe('2026-04-06')
expect(segs[0].endDate).toBe('2026-04-08')
})
it('merges segments within 5-day återinsjuknande window', () => {
// Sick Mon-Wed, gap Thu-Fri-Sat-Sun-Mon (5 days), sick Tue
// Gap from last sick (Wed Apr 8) to next (Tue Apr 14) = 6 calendar days → new period
const segs1 = buildSjukloneperioder(['2026-04-06', '2026-04-07', '2026-04-08', '2026-04-14'])
expect(segs1).toHaveLength(2)
// Gap of exactly 5 days → same period
// Wed Apr 8 → Mon Apr 13 = 5 days
const segs2 = buildSjukloneperioder(['2026-04-06', '2026-04-07', '2026-04-08', '2026-04-13'])
expect(segs2).toHaveLength(1)
expect(segs2[0].sickDayCount).toBe(4)
})
it('starts a new period when gap is >5 days', () => {
const segs = buildSjukloneperioder(['2026-04-06', '2026-04-13'])
// gap = 7 → new period
expect(segs).toHaveLength(2)
})
it('returns empty for empty input', () => {
expect(buildSjukloneperioder([])).toEqual([])
})
it('deduplicates duplicate dates', () => {
const segs = buildSjukloneperioder(['2026-04-06', '2026-04-06', '2026-04-07'])
expect(segs).toHaveLength(1)
expect(segs[0].sickDayCount).toBe(2)
})
})
describe('deriveAbsenceLineItems — sick', () => {
it('emits karensavdrag for a single sick day', () => {
const result = deriveAbsenceLineItems(
baseInput({ periodDays: days([['2026-04-06', 'sick']]) }),
)
const karens = result.lineItems.find(li => li.item_type === 'sick_karens')
expect(karens).toBeDefined()
expect(karens!.quantity).toBe(1)
expect(karens!.amount).toBeLessThan(0)
expect(result.lineItems.find(li => li.item_type === 'sick_day2_14')).toBeUndefined()
expect(result.aggregated.sickDays).toBe(1)
})
it('emits karens + day-2-14 for a 5-day period', () => {
const result = deriveAbsenceLineItems(
baseInput({
periodDays: days([
['2026-04-06', 'sick'],
['2026-04-07', 'sick'],
['2026-04-08', 'sick'],
['2026-04-09', 'sick'],
['2026-04-10', 'sick'],
]),
}),
)
const karens = result.lineItems.find(li => li.item_type === 'sick_karens')
const day2_14 = result.lineItems.find(li => li.item_type === 'sick_day2_14')
expect(karens).toBeDefined()
expect(day2_14).toBeDefined()
expect(day2_14!.quantity).toBe(4) // days 2-5 of segment
expect(result.flagFkReporting).toBe(false)
})
it('flags läkarintyg when day-8 reached (segment day 8+)', () => {
const periodDays = days(
Array.from({ length: 9 }, (_, i): [string, 'sick'] => [`2026-04-${String(6 + i).padStart(2, '0')}`, 'sick']),
)
const result = deriveAbsenceLineItems(baseInput({ periodDays }))
expect(result.flagLakarintyg).toBe(true)
})
it('flags FK reporting when segment passes day 14', () => {
// 16 consecutive sick days
const periodDays = days(
Array.from({ length: 16 }, (_, i): [string, 'sick'] => {
const day = String(6 + i).padStart(2, '0')
return [`2026-04-${day}`, 'sick']
}),
)
const result = deriveAbsenceLineItems(baseInput({ periodDays }))
expect(result.flagFkReporting).toBe(true)
const day15 = result.lineItems.find(li => li.item_type === 'sick_day15_plus')
expect(day15).toBeDefined()
expect(day15!.quantity).toBe(2) // days 15, 16
})
it('suppresses karens via återinsjuknande when segment started in lookback', () => {
// Prior segment: Apr 1-3. Current period sick day: Apr 6 (gap 3 days → merge).
// Segment now spans Apr 1-6. Period day Apr 6 is segment day 6 → day-2-14, no new karens.
const result = deriveAbsenceLineItems(
baseInput({
periodDays: days([['2026-04-06', 'sick']]),
lookbackSickDates: ['2026-04-01', '2026-04-02', '2026-04-03'],
}),
)
expect(result.lineItems.find(li => li.item_type === 'sick_karens')).toBeUndefined()
const day2_14 = result.lineItems.find(li => li.item_type === 'sick_day2_14')
expect(day2_14).toBeDefined()
expect(day2_14!.quantity).toBe(1)
})
it('suppresses karens when högriskskydd cap reached', () => {
// 10 prior single-day karens-eligible periods, each separated by >5 days
const lookback: string[] = []
for (let i = 0; i < 10; i++) {
// periods on the 1st of each prior month
const month = ((4 - 1 + 12 - i - 1) % 12) + 1 // months 3, 2, 1, 12, ...
const year = i < 3 ? 2026 : 2025
lookback.push(`${year}-${String(month).padStart(2, '0')}-01`)
}
const result = deriveAbsenceLineItems(
baseInput({
periodDays: days([['2026-04-15', 'sick']]),
lookbackSickDates: lookback,
}),
)
// 10 prior karens in 12-month window → this 11th is suppressed
expect(result.lineItems.find(li => li.item_type === 'sick_karens')).toBeUndefined()
})
})
describe('deriveAbsenceLineItems — VAB', () => {
it('emits VAB line item with deduction', () => {
const result = deriveAbsenceLineItems(
baseInput({
periodDays: days([
['2026-04-10', 'vab'],
['2026-04-11', 'vab'],
]),
}),
)
const vab = result.lineItems.find(li => li.item_type === 'vab')
expect(vab).toBeDefined()
expect(vab!.quantity).toBe(2)
expect(vab!.is_vacation_basis).toBe(true) // ≤120 days YTD
expect(result.aggregated.vabDays).toBe(2)
})
it('marks VAB non-vacation-basis when YTD >= 120', () => {
const result = deriveAbsenceLineItems(
baseInput({
periodDays: days([['2026-04-10', 'vab']]),
vabDaysYtd: 120,
}),
)
const vab = result.lineItems.find(li => li.item_type === 'vab')
expect(vab!.is_vacation_basis).toBe(false)
})
})
describe('deriveAbsenceLineItems — parental', () => {
it('emits parental line item with deduction', () => {
const result = deriveAbsenceLineItems(
baseInput({
periodDays: days([
['2026-04-10', 'parental'],
['2026-04-11', 'parental'],
['2026-04-12', 'parental'],
]),
}),
)
const parental = result.lineItems.find(li => li.item_type === 'parental_leave')
expect(parental).toBeDefined()
expect(parental!.quantity).toBe(3)
expect(result.aggregated.parentalDays).toBe(3)
})
})
describe('deriveAbsenceLineItems — empty', () => {
it('returns empty result for no absence', () => {
const result = deriveAbsenceLineItems(baseInput())
expect(result.lineItems).toEqual([])
expect(result.aggregated).toEqual({ sickDays: 0, vabDays: 0, parentalDays: 0 })
expect(result.flagFkReporting).toBe(false)
expect(result.flagLakarintyg).toBe(false)
})
})
@@ -0,0 +1,89 @@
import { randomUUID } from 'crypto'
import { describe, expect, it } from 'vitest'
import { seedCompany } from '@/tests/pg/fixtures'
import { getPool, withUserContext } from '@/tests/pg/setup'
/**
* RLS smoke for salary_absence_days. Sjuklöneperiod / återinsjuknande /
* högriskskydd derivation already has unit coverage; this test only locks
* in tenant isolation, which mocked Supabase clients can't exercise.
*/
async function insertEmployee(params: {
userId: string
companyId: string
}): Promise<string> {
const id = randomUUID()
// personnummer must be 12 digits; last4 mirrors the last four chars.
const pnr = '199001011234'
await getPool().query(
`INSERT INTO public.employees
(id, user_id, company_id, first_name, last_name, personnummer,
personnummer_last4, employment_start, monthly_salary, tax_table_number)
VALUES ($1, $2, $3, 'Test', 'Person', $4, '1234', '2026-01-01', 30000, 32)`,
[id, params.userId, params.companyId, pnr],
)
return id
}
async function insertAbsenceDay(params: {
companyId: string
employeeId: string
date: string
type?: string
}): Promise<string> {
const id = randomUUID()
await getPool().query(
`INSERT INTO public.salary_absence_days
(id, company_id, employee_id, absence_date, absence_type, hours)
VALUES ($1, $2, $3, $4, $5, 8)`,
[id, params.companyId, params.employeeId, params.date, params.type ?? 'sick'],
)
return id
}
describe('salary_absence_days.pg — RLS tenant isolation', () => {
it('a user only sees absence days for their own company', async () => {
const a = await seedCompany()
const b = await seedCompany()
const empA = await insertEmployee({ userId: a.userId, companyId: a.companyId })
const empB = await insertEmployee({ userId: b.userId, companyId: b.companyId })
await insertAbsenceDay({ companyId: a.companyId, employeeId: empA, date: '2026-04-15' })
await insertAbsenceDay({ companyId: b.companyId, employeeId: empB, date: '2026-04-16' })
const rowsA = await withUserContext(a.userId, async (client) => {
const res = await client.query<{ company_id: string }>(
`SELECT company_id FROM public.salary_absence_days`,
)
return res.rows
})
expect(rowsA).toHaveLength(1)
expect(rowsA[0]!.company_id).toBe(a.companyId)
})
it('blocks INSERT into another tenant via WITH CHECK', async () => {
const a = await seedCompany()
const b = await seedCompany()
const empB = await insertEmployee({ userId: b.userId, companyId: b.companyId })
await expect(
withUserContext(a.userId, async (client) => {
return client.query(
`INSERT INTO public.salary_absence_days
(company_id, employee_id, absence_date, absence_type, hours)
VALUES ($1, $2, '2026-04-17', 'sick', 8)`,
[b.companyId, empB],
)
}),
).rejects.toThrow(/row-level security/i)
})
it('enforces unique (employee_id, absence_date, absence_type)', async () => {
const a = await seedCompany()
const empA = await insertEmployee({ userId: a.userId, companyId: a.companyId })
await insertAbsenceDay({ companyId: a.companyId, employeeId: empA, date: '2026-04-15' })
await expect(
insertAbsenceDay({ companyId: a.companyId, employeeId: empA, date: '2026-04-15' }),
).rejects.toThrow(/duplicate key|unique/i)
})
})
+107 -8
View File
@@ -21,17 +21,49 @@ import { getBranding } from '@/lib/branding/service'
* CRITICAL: FK570 (specifikationsnummer) must stay consistent per employee.
* Corrections are detected by Skatteverket matching the same FK570.
*
* NOT HANDLED HERE (future work):
* - <Franvarouppgift>: separate top-level section for parental leave events
* (FK821 FranvaroDatum, FK823 FranvaroTyp={TILLFALLIG_FORALDRAPENNING|
* FORALDRAPENNING}, etc.). Requires per-event date records, not a simple
* day count. Per-employee sick days are NOT reported via AGI at all —
* they go to Försäkringskassan separately.
* Frånvarouppgift emission is implemented per Skatteverket SKV 4785 + the
* "Frånvarouppgift i samband med Arbetsgivardeklaration" technical doc:
* - One <gem:Franvarouppgift> per (employee, date, specifikationsnummer)
* - Sibling of <gem:Blankett>, top-level under <Skatteverket>
* - FranvaroChoice contains FranvaroTyp (TILLFALLIG_FORALDRAPENNING for VAB,
* FORALDRAPENNING for parental leave) — the borttag flow is not used.
* - Hours emitted via FranvaroTimmarTFP (FK825) for VAB or FranvaroTimmarFP
* (FK827) for parental. The procent variants (824/826) are not used —
* gnubok tracks hours, not percent.
* - FranvaroSpecifikationsnummer is assigned 1-based per (employee, period),
* ordered by date. Skatteverket replaces a Frånvarouppgift on match of
* (BetalningsmottagarId, FranvaroDatum, FranvaroSpecifikationsnummer,
* RedovisningsPeriod, AgRegistreradId) — for stable replacement across
* re-generations the numbering must persist; if dates are added/removed
* mid-period the indices shift. First-submit is fine; correction
* stability is a follow-up TODO (persist event → number mapping).
* - Periods before 202501 emit no Frånvarouppgift (Skatteverket rejects).
*
* Per-employee sick days are NOT reported via AGI under any version — they
* go to Försäkringskassan separately. The company-level FK499
* TotalSjuklonekostnad in HU is correctly emitted from sick_day2_14 line
* items × dailyRate × 0.80 (see agi/xml/route.ts).
*/
const INSTANS_NS = 'http://xmls.skatteverket.se/se/skatteverket/da/instans/schema/1.1'
const KOMPONENT_NS = 'http://xmls.skatteverket.se/se/skatteverket/da/komponent/schema/1.1'
/**
* One absence event for AGI Frånvarouppgift emission. Loaded from
* salary_absence_days (per-day records). Sick days are NOT included — they
* go to Försäkringskassan, not Skatteverket.
*/
export interface AGIAbsenceEvent {
/** YYYY-MM-DD — emitted as FK821 FranvaroDatum. */
date: string
/** Mapped to FranvaroTyp:
* 'vab' → TILLFALLIG_FORALDRAPENNING (FK825 hours field)
* 'parental' → FORALDRAPENNING (FK827 hours field) */
type: 'vab' | 'parental'
/** Hours absent on this date, 0.01–24.00. Defaults to 8 in salary_absence_days. */
hours: number
}
export interface AGIEmployeeData {
personnummer: string // Encrypted — decrypted for XML
specificationNumber: number // FK570 — MUST stay consistent per employee
@@ -47,10 +79,15 @@ export interface AGIEmployeeData {
benefitMeals?: number
/** @deprecated Per-employee sick days are not reported via AGI (goes to Försäkringskassan separately). Kept for snapshot compatibility. */
sickDays?: number
/** @deprecated VAB is reported via top-level <Franvarouppgift> as per-event records, not as an IU day count. Kept for snapshot compatibility. */
/** @deprecated VAB is reported via top-level <Franvarouppgift> as per-event records (see absenceEvents), not as an IU day count. Kept for snapshot compatibility. */
vabDays?: number
/** @deprecated Parental leave is reported via top-level <Franvarouppgift> as per-event records, not as an IU day count. Kept for snapshot compatibility. */
/** @deprecated Parental leave is reported via top-level <Franvarouppgift> as per-event records (see absenceEvents), not as an IU day count. Kept for snapshot compatibility. */
parentalDays?: number
/**
* Per-event absence records for the period. Drives <gem:Franvarouppgift>
* emission. VAB and parental only — sick days excluded by spec (FK).
*/
absenceEvents?: AGIAbsenceEvent[]
}
export interface AGICompanyData {
@@ -334,6 +371,56 @@ export function generateAGIXml(
lines.push(' </gem:Blankett>')
}
// ── Frånvarouppgift (per-event VAB/parental records, FK820-827) ───────
// Skatteverket only accepts Frånvarouppgift from period 202501 onward.
const periodAsNumber = company.periodYear * 100 + company.periodMonth
if (periodAsNumber >= 202501) {
for (const emp of employees) {
if (!emp.absenceEvents || emp.absenceEvents.length === 0) continue
let pnr: string
try {
pnr = decryptPersonnummer(emp.personnummer)
} catch {
// Already surfaced as a hard error in the IU loop above; skip silently here.
continue
}
// Stable specifikationsnummer per (employee, period): sort by date,
// then 1-based index. Two events on the same date get sequential
// numbers. The unique key in the Skatteverket spec is
// (BetalningsmottagarId, FranvaroDatum, FranvaroSpecifikationsnummer,
// RedovisningsPeriod, AgRegistreradId), so within one employee+date
// duplicates of the same number replace.
const sorted = [...emp.absenceEvents].sort((a, b) => {
if (a.date < b.date) return -1
if (a.date > b.date) return 1
return 0
})
sorted.forEach((event, idx) => {
const specNumber = idx + 1
const isVab = event.type === 'vab'
const franvaroTyp = isVab ? 'TILLFALLIG_FORALDRAPENNING' : 'FORALDRAPENNING'
const hoursElement = isVab ? 'FranvaroTimmarTFP' : 'FranvaroTimmarFP'
const hoursFaltkod = isVab ? '825' : '827'
lines.push(' <gem:Franvarouppgift>')
// Element order follows the spec example file (SKV 4785 doc, section 4).
lines.push(` <gem:AgRegistreradId faltkod="201">${orgIdentitet}</gem:AgRegistreradId>`)
lines.push(` <gem:RedovisningsPeriod faltkod="006">${period}</gem:RedovisningsPeriod>`)
lines.push(` <gem:FranvaroDatum faltkod="821">${event.date}</gem:FranvaroDatum>`)
lines.push(` <gem:BetalningsmottagarId faltkod="215">${pnr}</gem:BetalningsmottagarId>`)
lines.push(` <gem:FranvaroSpecifikationsnummer faltkod="822">${specNumber}</gem:FranvaroSpecifikationsnummer>`)
lines.push(' <gem:FranvaroChoice>')
lines.push(` <gem:FranvaroTyp faltkod="823">${franvaroTyp}</gem:FranvaroTyp>`)
lines.push(' </gem:FranvaroChoice>')
lines.push(` <gem:${hoursElement} faltkod="${hoursFaltkod}">${formatHours(event.hours)}</gem:${hoursElement}>`)
lines.push(' </gem:Franvarouppgift>')
})
}
}
lines.push('</Skatteverket>')
return lines.join('\n')
@@ -383,3 +470,15 @@ function escapeXml(str: string): string {
function formatAmount(amount: number): string {
return Math.round(amount).toString()
}
/**
* Format hours for FranvaroTimmarTFP/FP (FK825/827).
* Spec range: 0.01 – 24.00, up to two decimals. Whole-hour values emit
* without trailing zeros (e.g. 8 → "8") to match Skatteverket's example
* file ("4" not "4.00"); fractional values keep their decimals.
*/
function formatHours(hours: number): string {
const clamped = Math.max(0.01, Math.min(24, hours))
const rounded = Math.round(clamped * 100) / 100
return Number.isInteger(rounded) ? String(rounded) : rounded.toFixed(2).replace(/0+$/, '').replace(/\.$/, '')
}
+416
View File
@@ -0,0 +1,416 @@
import type { SupabaseClient } from '@supabase/supabase-js'
import type { PayrollConfig } from './payroll-config'
import {
calculateVabDeduction,
calculateParentalLeaveDeduction,
} from './absence-calculator'
/**
* Derive payroll line items from per-day absence records.
*
* Why this lives outside the existing absence-calculator: those formulas
* still take `sickDays: number`. They cannot determine sjuklöneperiod
* boundaries, återinsjuknande, or högriskskydd — those depend on actual
* dates, which now live in `salary_absence_days`. This module is the
* bridge: it walks the per-day records and emits correctly-classified
* line items.
*
* Swedish payroll rules implemented:
* - **Sjuklöneperiod** (Sjuklönelagen) = first sick day → calendar-day 14.
* Day 1 is karensavdrag (one per period). Days 2–14 are sjuklön at 80%.
* Day 15+ is Försäkringskassan; employer pays nothing but must report.
* - **Återinsjuknande**: if the next sick day is within 5 calendar days of
* the previous sjuklöneperiod's last day, both merge — no new karens.
* - **Allmänt högriskskydd**: max 10 karensavdrag per rolling 12-month
* window (inclusive of the new one). The 11th is suppressed.
*
* For VAB and parental leave, days are aggregated within the pay period and
* forwarded to the existing calculators with YTD context.
*/
export type AbsenceType =
| 'sick'
| 'vab'
| 'parental'
| 'pregnancy'
| 'care_relative'
| 'study'
| 'other_leave'
export interface AbsenceDay {
absence_date: string // YYYY-MM-DD
absence_type: AbsenceType
hours: number
}
export interface DerivedLineItem {
item_type: 'sick_karens' | 'sick_day2_14' | 'sick_day15_plus' | 'vab' | 'parental_leave'
description: string
quantity: number
amount: number
is_taxable: boolean
is_avgift_basis: boolean
is_vacation_basis: boolean
is_gross_deduction: boolean
}
export interface AggregatedCounts {
sickDays: number
vabDays: number
parentalDays: number
}
export interface DeriveResult {
lineItems: DerivedLineItem[]
aggregated: AggregatedCounts
/** At least one sick day in the pay period fell on segment day 15+ (Försäkringskassan reporting required). */
flagFkReporting: boolean
/** At least one segment passed day 8 in the period (läkarintyg expected). */
flagLakarintyg: boolean
}
interface SjukloneperiodSegment {
startDate: string
endDate: string
/** Number of *sick days* in this merged segment (not calendar days). */
sickDayCount: number
/** True if this segment is the continuation of a prior segment via
* återinsjuknande (gap 1–5 calendar days). No new karensavdrag. */
isAterinsjuknande: boolean
}
const ONE_DAY_MS = 24 * 60 * 60 * 1000
function dateOnly(s: string): Date {
return new Date(`${s}T00:00:00Z`)
}
function daysBetween(a: string, b: string): number {
return Math.round((dateOnly(b).getTime() - dateOnly(a).getTime()) / ONE_DAY_MS)
}
function addDays(d: string, n: number): string {
const t = new Date(dateOnly(d).getTime() + n * ONE_DAY_MS)
return t.toISOString().slice(0, 10)
}
/**
* Walk the (sorted ascending) sick dates and merge them into sjuklöneperioder
* using the SjLL återinsjuknande rule: gap of 1–5 calendar days = same
* period continues; gap ≥ 6 = new period.
*/
export function buildSjukloneperioder(sickDates: string[]): SjukloneperiodSegment[] {
if (sickDates.length === 0) return []
const sorted = [...new Set(sickDates)].sort()
const segments: SjukloneperiodSegment[] = []
let startDate = sorted[0]
let endDate = sorted[0]
let count = 1
const flush = (gapToNext: number | null) => {
segments.push({
startDate,
endDate,
sickDayCount: count,
// The *first* segment is never återinsjuknande (no prior period).
// For subsequent segments, this flag is set below when starting a new one.
isAterinsjuknande: false,
})
void gapToNext
}
for (let i = 1; i < sorted.length; i++) {
const date = sorted[i]
const gap = daysBetween(endDate, date)
if (gap === 0) continue
if (gap >= 1 && gap <= 5) {
// Within 5 calendar days — same period (contiguous OR återinsjuknande)
endDate = date
count += 1
continue
}
// gap > 5 — close current segment, start new one
flush(gap)
startDate = date
endDate = date
count = 1
}
flush(null)
// Annotate isAterinsjuknande based on inter-segment gap (only meaningful if
// the gap from prior segment's end to this segment's start is 1–5 days,
// which the merge logic above already excludes — so this stays false. The
// återinsjuknande logic is fully captured by the merge above; we keep the
// flag for caller introspection if they pass in pre-segmented data.)
return segments
}
export interface DeriveInput {
monthlySalary: number
payrollConfig: PayrollConfig
/** Absence rows in the pay period being calculated. */
periodDays: AbsenceDay[]
/** All sick dates in the prior 12 months (excluding the period). Needed
* to merge segments across pay periods (a period that started in the
* previous month already consumed some of the 14-day window) and to
* count karensavdrag for högriskskydd. */
lookbackSickDates: string[]
/** Year-to-date VAB days for this employee, excluding the current period. */
vabDaysYtd: number
/** Parental leave days in the current pregnancy window (best-effort:
* defaults to calendar-year aggregate). */
parentalDaysPregnancyYtd: number
}
export function deriveAbsenceLineItems(input: DeriveInput): DeriveResult {
const { monthlySalary, payrollConfig, periodDays } = input
const lineItems: DerivedLineItem[] = []
const r = (x: number) => Math.round(x * 100) / 100
const periodSickDates = periodDays
.filter(d => d.absence_type === 'sick')
.map(d => d.absence_date)
const vabDays = periodDays.filter(d => d.absence_type === 'vab')
const parentalDays = periodDays.filter(d => d.absence_type === 'parental')
let flagFkReporting = false
let flagLakarintyg = false
if (periodSickDates.length > 0) {
const periodMin = periodSickDates[0]
// Build segments over (lookback ∪ period). Segments may straddle the
// boundary; we need the full picture to classify each period day's
// index within its segment.
const allSickDates = [...input.lookbackSickDates, ...periodSickDates]
const segments = buildSjukloneperioder(allSickDates)
// Allmänt högriskskydd (Sjuklönelagen 11§): from the 11th sjuklöneperiod
// within a rolling 12-month window, no karensavdrag is made.
//
// Interpretation: we count *sjuklöneperioder* in the lookback window. The
// law's phrasing — "från och med den 11:e sjukperioden under en
// tolvmånadersperiod görs inget karensavdrag" — keys the cap to the
// period count. An alternative reading is that cap-suppressed periods
// shouldn't count toward future windows (only periods that actually
// had karens deducted). That requires persisting per-period karens-
// deduction state, which gnubok doesn't yet do. The period-count
// reading can over-suppress karens for an employee who hits the cap
// repeatedly — softer error than the opposite.
//
// TODO: persist per-period karens deduction state if the period-count
// reading produces complaints in the field.
const cap = payrollConfig.maxKarensavdragPerYear ?? 10
const cutoff = addDays(periodMin, -365)
const lookbackOnlySegments = buildSjukloneperioder(
input.lookbackSickDates.filter(d => d >= cutoff),
)
let karensInWindow = lookbackOnlySegments.length
const dailyRate = r(monthlySalary / 21)
const weeklyRate = r(monthlySalary * 12 / 52 * payrollConfig.sjuklonRate)
const karensAmount = r(weeklyRate * payrollConfig.karensavdragFactor)
let day2_14CountTotal = 0
let day15PlusCountTotal = 0
// Walk each segment that touches the period.
for (const seg of segments) {
// Skip segments that don't touch the period at all.
if (seg.endDate < periodMin) continue
if (seg.startDate > periodSickDates[periodSickDates.length - 1]) continue
const segmentStartsInPeriod = seg.startDate >= periodMin
// Karens for the segment? Day 1 of segment, only if it starts in this
// period and the högriskskydd cap isn't hit. (If the segment started
// in a prior pay period, the karens was already booked there; nothing
// to emit here.)
if (segmentStartsInPeriod) {
if (karensInWindow < cap) {
lineItems.push({
item_type: 'sick_karens',
description: `Karensavdrag (${seg.startDate})`,
quantity: 1,
amount: -karensAmount,
is_taxable: true,
is_avgift_basis: true,
is_vacation_basis: false,
is_gross_deduction: true,
})
karensInWindow += 1
} else {
// Suppressed by allmänt högriskskydd. The employee keeps day-1 pay
// (no karens deduction). Day 1 still consumed from the 14-day
// window but treated as paid normal — emit nothing for it.
}
}
// Classify each *period* sick day in this segment by its segment day
// index (calendar days from segment start, 1-based).
for (const d of periodSickDates) {
if (d < seg.startDate || d > seg.endDate) continue
const segDayIndex = daysBetween(seg.startDate, d) + 1
if (segDayIndex === 1 && segmentStartsInPeriod) {
// already accounted for as karens (or suppressed); skip
continue
}
if (segDayIndex >= 2 && segDayIndex <= 14) {
day2_14CountTotal += 1
if (segDayIndex >= 8) flagLakarintyg = true
} else if (segDayIndex >= 15) {
day15PlusCountTotal += 1
flagFkReporting = true
}
}
}
if (day2_14CountTotal > 0) {
const lostPay = r(dailyRate * day2_14CountTotal)
const sjuklon = r(dailyRate * payrollConfig.sjuklonRate * day2_14CountTotal)
lineItems.push({
item_type: 'sick_day2_14',
description: `Sjuklön dag 2–14 (${day2_14CountTotal} dagar)`,
quantity: day2_14CountTotal,
// Net deduction vs full pay = lostPay - sjuklon (employer pays 80%).
amount: -(lostPay - sjuklon),
is_taxable: true,
is_avgift_basis: true,
is_vacation_basis: true,
is_gross_deduction: true,
})
}
if (day15PlusCountTotal > 0) {
const lostPay = r(dailyRate * day15PlusCountTotal)
lineItems.push({
item_type: 'sick_day15_plus',
description: `Sjukfrånvaro dag 15+ (FK) (${day15PlusCountTotal} dagar)`,
quantity: day15PlusCountTotal,
// Employer pays nothing — full daily rate deducted.
amount: -lostPay,
is_taxable: true,
is_avgift_basis: false,
is_vacation_basis: false,
is_gross_deduction: true,
})
}
}
// ── VAB ────────────────────────────────────────────────────────────────
const vabCount = vabDays.length
if (vabCount > 0) {
const vab = calculateVabDeduction(monthlySalary, vabCount, input.vabDaysYtd)
lineItems.push({
item_type: 'vab',
description: `VAB (${vabCount} dagar)`,
quantity: vabCount,
amount: -vab.deduction,
is_taxable: true,
is_avgift_basis: true,
is_vacation_basis: vab.semesterGrundande,
is_gross_deduction: true,
})
}
// ── Parental leave ─────────────────────────────────────────────────────
const parentalCount = parentalDays.length
if (parentalCount > 0) {
const parental = calculateParentalLeaveDeduction(
monthlySalary,
parentalCount,
input.parentalDaysPregnancyYtd,
)
lineItems.push({
item_type: 'parental_leave',
description: `Föräldraledighet (${parentalCount} dagar)`,
quantity: parentalCount,
amount: -parental.deduction,
is_taxable: true,
is_avgift_basis: true,
is_vacation_basis: parental.semesterGrundande,
is_gross_deduction: true,
})
}
return {
lineItems,
aggregated: {
sickDays: periodSickDates.length,
vabDays: vabCount,
parentalDays: parentalCount,
},
flagFkReporting,
flagLakarintyg,
}
}
/**
* Convenience: load all DB inputs and derive in one call. Used by the
* salary calculate route.
*/
export async function loadAndDeriveAbsence(params: {
supabase: SupabaseClient
companyId: string
employeeId: string
monthlySalary: number
payrollConfig: PayrollConfig
periodStart: string
periodEnd: string
}): Promise<DeriveResult> {
const { supabase, companyId, employeeId, periodStart, periodEnd } = params
const { data: periodRows, error: periodErr } = await supabase
.from('salary_absence_days')
.select('absence_date, absence_type, hours')
.eq('company_id', companyId)
.eq('employee_id', employeeId)
.gte('absence_date', periodStart)
.lte('absence_date', periodEnd)
.order('absence_date', { ascending: true })
if (periodErr) throw new Error(`Failed to load absence days: ${periodErr.message}`)
const periodDays = (periodRows ?? []) as AbsenceDay[]
const lookbackStart = addDays(periodStart, -365)
const { data: lookbackRows, error: lookbackErr } = await supabase
.from('salary_absence_days')
.select('absence_date')
.eq('company_id', companyId)
.eq('employee_id', employeeId)
.eq('absence_type', 'sick')
.gte('absence_date', lookbackStart)
.lt('absence_date', periodStart)
if (lookbackErr) throw new Error(`Failed to load absence lookback: ${lookbackErr.message}`)
const lookbackSickDates = (lookbackRows ?? []).map(r => r.absence_date as string)
const yearStart = `${periodStart.slice(0, 4)}-01-01`
const { data: vabYtd } = await supabase
.from('salary_absence_days')
.select('absence_date')
.eq('company_id', companyId)
.eq('employee_id', employeeId)
.eq('absence_type', 'vab')
.gte('absence_date', yearStart)
.lt('absence_date', periodStart)
const vabDaysYtd = vabYtd?.length ?? 0
const { data: parentalYtd } = await supabase
.from('salary_absence_days')
.select('absence_date')
.eq('company_id', companyId)
.eq('employee_id', employeeId)
.eq('absence_type', 'parental')
.gte('absence_date', yearStart)
.lt('absence_date', periodStart)
const parentalDaysPregnancyYtd = parentalYtd?.length ?? 0
return deriveAbsenceLineItems({
monthlySalary: params.monthlySalary,
payrollConfig: params.payrollConfig,
periodDays,
lookbackSickDates,
vabDaysYtd,
parentalDaysPregnancyYtd,
})
}
@@ -0,0 +1,88 @@
-- Migration: salary_absence_days — per-day absence records for payroll
--
-- Why this exists: Swedish payroll law requires per-day absence tracking,
-- not aggregated day counts. Several rules collapse without dates:
-- * Karensavdrag is once per sjuklöneperiod (Sjuklönelagen). The period is
-- defined by contiguous sick days; you cannot determine "is this a new
-- period?" without dates.
-- * Återinsjuknande: if the employee falls sick again within 5 calendar
-- days, the same period continues — no new karensavdrag. Requires actual
-- dates, not counts.
-- * Allmänt högriskskydd caps karensavdrag at 10 per rolling 12-month
-- period — requires per-day timestamps across pay periods.
-- * Day 8 läkarintyg flag and day 14/15 transition to Försäkringskassan
-- are per-period boundaries.
-- * AGI 2025+ <Frånvarouppgift> reports parental leave as per-event date
-- records (forwarded to Försäkringskassan), not as day counts.
--
-- The previous model stored aggregated counts on salary_run_employees
-- (sick_days, vab_days, parental_days) summed from line_items.quantity.
-- Those columns remain as the materialized aggregate — they are now
-- *derived* from this table at calculation time, not user-entered.
CREATE TABLE public.salary_absence_days (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
company_id UUID NOT NULL REFERENCES companies(id) ON DELETE CASCADE,
employee_id UUID NOT NULL REFERENCES employees(id) ON DELETE CASCADE,
-- Optional link to the pay run that already absorbed this day. Null while
-- the employee marks future absence before a run exists.
salary_run_employee_id UUID REFERENCES salary_run_employees(id) ON DELETE SET NULL,
absence_date DATE NOT NULL,
-- 'sick' covers all sjukfrånvaro days; karens vs day-2-14 vs day-15+ are
-- *derived* at calculation time from the date sequence and högriskskydd
-- state. Storing them denormalizes and creates correctness risks if a user
-- backfills an earlier sick day after the fact.
absence_type TEXT NOT NULL CHECK (absence_type IN (
'sick', -- sjukfrånvaro
'vab', -- vård av barn (tillfällig föräldrapenning)
'parental', -- föräldraledighet (föräldrapenning)
'pregnancy', -- graviditetspenning
'care_relative', -- närståendepenning
'study', -- studieledig
'other_leave'
)),
-- Hours absent on this date. Defaults to 8.0 for a full scheduled day.
-- Allows partial-day VAB / sick (e.g. 4 hours for half-day pickup).
hours NUMERIC(5, 2) NOT NULL DEFAULT 8.0
CHECK (hours > 0 AND hours <= 24),
notes TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
-- One row per employee+date+type. An employee can have both a sick day and
-- (rarely) another type on the same date, but not two of the same type.
CREATE UNIQUE INDEX idx_salary_absence_days_unique
ON public.salary_absence_days (employee_id, absence_date, absence_type);
-- Range queries by employee+date are the dominant access pattern: pay-period
-- aggregation, återinsjuknande lookback, högriskskydd 12-month rolling cap.
CREATE INDEX idx_salary_absence_days_employee_date
ON public.salary_absence_days (employee_id, absence_date);
-- Lookup by run, used when the calculator materializes line items.
CREATE INDEX idx_salary_absence_days_run
ON public.salary_absence_days (salary_run_employee_id)
WHERE salary_run_employee_id IS NOT NULL;
-- Company-level scans (e.g. AGI Frånvarouppgift section across all employees).
CREATE INDEX idx_salary_absence_days_company_date
ON public.salary_absence_days (company_id, absence_date);
ALTER TABLE public.salary_absence_days ENABLE ROW LEVEL SECURITY;
CREATE POLICY "salary_absence_days_select" ON public.salary_absence_days
FOR SELECT USING (company_id IN (SELECT public.user_company_ids()));
CREATE POLICY "salary_absence_days_insert" ON public.salary_absence_days
FOR INSERT WITH CHECK (company_id IN (SELECT public.user_company_ids()));
CREATE POLICY "salary_absence_days_update" ON public.salary_absence_days
FOR UPDATE USING (company_id IN (SELECT public.user_company_ids()))
WITH CHECK (company_id IN (SELECT public.user_company_ids()));
CREATE POLICY "salary_absence_days_delete" ON public.salary_absence_days
FOR DELETE USING (company_id IN (SELECT public.user_company_ids()));
CREATE TRIGGER salary_absence_days_updated_at
BEFORE UPDATE ON public.salary_absence_days
FOR EACH ROW EXECUTE FUNCTION public.update_updated_at_column();
NOTIFY pgrst, 'reload schema';
@@ -0,0 +1,78 @@
-- Skattekonto transaction store
--
-- Mirrors transactions fetched from Skatteverket's Skattekonto API v2:
-- GET /skattekonton/{omfragad}/transaktioner
-- Each row is either a booked (tidigare) or upcoming (kommande) transaction.
-- Amounts are kept in SEK matching Skatteverket's sign convention
-- (positive = credit on the tax account, negative = debit / debt).
--
-- Idempotent ingestion: dedup_key is unique per company. When a "kommande"
-- transaction graduates to "tidigare", the same dedup_key resolves and the
-- row is updated in place (status flips, transaktionsidentitet populated).
CREATE TABLE public.skattekonto_transactions (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
company_id UUID NOT NULL REFERENCES public.companies ON DELETE CASCADE,
-- Skatteverket's stable transaction id. Present on tidigare; often null
-- on kommande. We do NOT use it as a unique constraint because it can
-- be missing — see dedup_key below.
transaktionsidentitet BIGINT,
-- Stable dedup key: transaktionsidentitet when present, otherwise a
-- sha256 hex of (transaktionsdatum|beloppSkatteverket|transaktionstext).
-- Application code computes this; the unique constraint below enforces it.
dedup_key TEXT NOT NULL,
transaktionsdatum DATE NOT NULL,
forfallodatum DATE, -- only on kommande
ranteberakningsdatum DATE,
transaktionstext TEXT NOT NULL,
belopp_skatteverket NUMERIC(14, 2) NOT NULL,
belopp_kronofogden NUMERIC(14, 2),
status TEXT NOT NULL CHECK (status IN ('booked', 'upcoming')),
-- When the user clicks "Bokför", a draft journal entry is created and
-- linked here. SET NULL on entry delete so this row can be re-bookförd.
journal_entry_id UUID REFERENCES public.journal_entries ON DELETE SET NULL,
imported_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE (company_id, dedup_key)
);
CREATE INDEX skattekonto_transactions_company_date_idx
ON public.skattekonto_transactions (company_id, transaktionsdatum DESC);
CREATE INDEX skattekonto_transactions_company_status_idx
ON public.skattekonto_transactions (company_id, status);
-- RLS — company-scoped via the user_company_ids() helper
ALTER TABLE public.skattekonto_transactions ENABLE ROW LEVEL SECURITY;
CREATE POLICY "Users see skattekonto transactions for their companies"
ON public.skattekonto_transactions FOR SELECT
USING (company_id IN (SELECT public.user_company_ids()));
CREATE POLICY "Users insert skattekonto transactions for their companies"
ON public.skattekonto_transactions FOR INSERT
WITH CHECK (company_id IN (SELECT public.user_company_ids()));
CREATE POLICY "Users update skattekonto transactions for their companies"
ON public.skattekonto_transactions FOR UPDATE
USING (company_id IN (SELECT public.user_company_ids()))
WITH CHECK (company_id IN (SELECT public.user_company_ids()));
CREATE POLICY "Users delete skattekonto transactions for their companies"
ON public.skattekonto_transactions FOR DELETE
USING (company_id IN (SELECT public.user_company_ids()));
CREATE TRIGGER update_skattekonto_transactions_updated_at
BEFORE UPDATE ON public.skattekonto_transactions
FOR EACH ROW
EXECUTE FUNCTION public.update_updated_at_column();
NOTIFY pgrst, 'reload schema';
@@ -0,0 +1,18 @@
-- Fix UPDATE RLS policy on skattekonto_transactions to include WITH CHECK.
--
-- The original migration (20260504160000_skattekonto_transactions) only
-- declared USING on the UPDATE policy. Without WITH CHECK, a row that
-- satisfies USING can be mutated to set company_id to a value the user
-- doesn't belong to, defeating tenant isolation.
--
-- Drop + recreate the policy with both clauses.
DROP POLICY IF EXISTS "Users update skattekonto transactions for their companies"
ON public.skattekonto_transactions;
CREATE POLICY "Users update skattekonto transactions for their companies"
ON public.skattekonto_transactions FOR UPDATE
USING (company_id IN (SELECT public.user_company_ids()))
WITH CHECK (company_id IN (SELECT public.user_company_ids()));
NOTIFY pgrst, 'reload schema';
+4
View File
@@ -35,6 +35,10 @@
{
"path": "/api/idempotency/cleanup/cron",
"schedule": "30 * * * *"
},
{
"path": "/api/extensions/skatteverket/skattekonto/sync/cron",
"schedule": "0 4 * * *"
}
]
}