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:
co-authored by
Claude Opus 4.7
parent
bb855d2ddc
commit
f3fd4c0822
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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å "Synkronisera nu".
|
||||
</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>
|
||||
)
|
||||
}
|
||||
@@ -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 } })
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 ? {
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
@@ -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'
|
||||
|
||||
@@ -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'
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
// ============================================================
|
||||
|
||||
@@ -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 } }
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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(/\.$/, '')
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
@@ -35,6 +35,10 @@
|
||||
{
|
||||
"path": "/api/idempotency/cleanup/cron",
|
||||
"schedule": "30 * * * *"
|
||||
},
|
||||
{
|
||||
"path": "/api/extensions/skatteverket/skattekonto/sync/cron",
|
||||
"schedule": "0 4 * * *"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user