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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

* fix: address PR review findings

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

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

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

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

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

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

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

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Jakob Wennberg
2026-05-04 19:01:21 +02:00
committed by GitHub
co-authored by Claude Opus 4.7
parent bb855d2ddc
commit f3fd4c0822
39 changed files with 5226 additions and 161 deletions
+33
View File
@@ -11,6 +11,27 @@ ensureInitialized()
// Heavy extension routes (SIE import, migration) need up to 5 minutes
export const maxDuration = 300
/**
* Per-extension runtime feature flags. Lets ops toggle an integration off
* without redeploying or removing it from extensions.config.json — useful
* for phased rollouts (dev tenants → design partners → general).
*
* The flag is checked on every request. If the env var is not exactly the
* string "true", the dispatcher returns 503 with `code: 'EXTENSION_DISABLED'`.
*
* Server-side env vars only — no NEXT_PUBLIC_ prefix. Next.js inlines
* NEXT_PUBLIC_* into the client bundle at build time, so a flip on Vercel
* without a redeploy would create split-brain (server returns 503,
* client still renders the enabled flow). UI panels detect the 503 by
* response code, not by reading the flag directly.
*/
const EXTENSION_FEATURE_FLAGS: Record<string, { envVar: string; disabledMessage: string }> = {
skatteverket: {
envVar: 'SKATTEVERKET_ENABLED',
disabledMessage: 'Skatteverket-integrationen är inte aktiverad i denna miljö.',
},
}
/**
* Match a request path against a route pattern.
* Supports :param wildcards (e.g., /:id/confirm).
@@ -70,6 +91,18 @@ async function handleRequest(
return NextResponse.json({ error: 'Extension not found' }, { status: 404 })
}
// Per-extension feature flags. Lets us toggle a single integration off
// mid-rollout without redeploying or removing it from extensions.config.json.
// The frontend (SkatteverketPanel, AGIPanel) inspects the 503 + code to
// render an "extension disabled" empty state.
const flag = EXTENSION_FEATURE_FLAGS[extensionId]
if (flag && process.env[flag.envVar] !== 'true') {
return NextResponse.json(
{ error: flag.disabledMessage, code: 'EXTENSION_DISABLED' },
{ status: 503 },
)
}
// Match route BEFORE auth so we can check skipAuth (e.g. OAuth callbacks)
let matchedRoute: ApiRouteDefinition | null = null
let extractedParams: Record<string, string> = {}
@@ -0,0 +1,166 @@
import { createClient } from '@supabase/supabase-js'
import { NextResponse } from 'next/server'
import { ensureInitialized } from '@/lib/init'
import { verifyCronSecret } from '@/lib/auth/cron'
import { createExtensionContext } from '@/lib/extensions/context-factory'
import { syncSkattekonto, SKATTEKONTO_LAST_SYNCED_AT_KEY } from '@/extensions/general/skatteverket/lib/skattekonto-sync'
import { SkatteverketAuthError } from '@/extensions/general/skatteverket/lib/api-client'
import { SkatteverketSkattekontoError } from '@/extensions/general/skatteverket/lib/skattekonto-client'
ensureInitialized()
export const maxDuration = 60
/**
* GET /api/extensions/skatteverket/skattekonto/sync/cron
*
* Daily skattekonto sync (cron 0 4 * * * — 04:00 UTC, 06:00 Swedish time).
* Pulls saldo + transactions for every company that has a connected
* Skatteverket token, and persists the results to skattekonto_transactions.
*
* Skips a company if it was synced within the last hour (cooldown),
* to keep manual + cron triggers from racing each other.
*
* Time budget: 50s (Vercel default 60s function timeout, 10s margin).
*
* Per-company errors are logged but do not abort the run — one expired
* token shouldn't block 49 other working syncs.
*/
export async function GET(request: Request) {
const authError = verifyCronSecret(request)
if (authError) return authError
// Respect the runtime extension toggle. When the integration is disabled
// the cron should no-op rather than spam Skatteverket with stale tokens.
if (process.env.SKATTEVERKET_ENABLED !== 'true') {
return NextResponse.json({ message: 'Skatteverket extension disabled', processed: 0 })
}
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL
const supabaseServiceKey = process.env.SUPABASE_SERVICE_ROLE_KEY
if (!supabaseUrl || !supabaseServiceKey) {
return NextResponse.json({ error: 'Missing Supabase configuration' }, { status: 500 })
}
const supabase = createClient(supabaseUrl, supabaseServiceKey)
// Find all companies with a connected token. The token row is keyed by
// user_id but carries company_id (added in the multi-tenant refactor).
const { data: tokens, error: tokensError } = await supabase
.from('skatteverket_tokens')
.select('user_id, company_id, expires_at, refresh_count')
.order('expires_at', { ascending: true })
.limit(50)
if (tokensError) {
console.error('[skattekonto-sync-cron] Failed to fetch tokens', {
message: tokensError.message,
code: tokensError.code,
})
return NextResponse.json({ error: 'Failed to fetch tokens' }, { status: 500 })
}
if (!tokens || tokens.length === 0) {
return NextResponse.json({ message: 'No connected tokens', processed: 0 })
}
const startTime = Date.now()
const TIME_BUDGET_MS = 50_000
const SYNC_COOLDOWN_MS = 60 * 60 * 1000 // 1 hour
type Result = {
userId: string
companyId: string
status: 'synced' | 'skipped_cooldown' | 'expired' | 'error'
booked?: number
upcoming?: number
error?: string
}
const results: Result[] = []
for (const token of tokens) {
if (Date.now() - startTime > TIME_BUDGET_MS) {
console.log(`[skattekonto-sync-cron] Time budget reached after ${results.length} tokens`)
break
}
const userId = token.user_id as string
const companyId = token.company_id as string | null
if (!companyId) {
// Pre-multi-tenant tokens may lack company_id. Skip — cannot scope.
results.push({ userId, companyId: '(missing)', status: 'error', error: 'No company_id on token' })
continue
}
try {
// Cooldown: skip if synced within the last hour.
const { data: lastSyncRow } = await supabase
.from('extension_data')
.select('value, updated_at')
.eq('company_id', companyId)
.eq('extension_id', 'skatteverket')
.eq('key', SKATTEKONTO_LAST_SYNCED_AT_KEY)
.maybeSingle()
const lastSyncedAt = lastSyncRow?.value as string | undefined
if (lastSyncedAt) {
const elapsed = Date.now() - new Date(lastSyncedAt).getTime()
if (elapsed < SYNC_COOLDOWN_MS) {
results.push({ userId, companyId, status: 'skipped_cooldown' })
continue
}
}
const ctx = createExtensionContext(supabase, userId, companyId, 'skatteverket')
const syncResult = await syncSkattekonto(ctx)
results.push({
userId,
companyId,
status: 'synced',
booked: syncResult.booked,
upcoming: syncResult.upcoming,
})
} catch (err) {
const message = err instanceof Error ? err.message : 'Unknown error'
// Expired token / refresh exhausted is a known outcome — surface it
// distinctly so ops can dashboard "X companies need to reconnect".
if (
err instanceof SkatteverketAuthError &&
(err.code === 'REFRESH_EXHAUSTED' || err.code === 'SESSION_EXPIRED' || err.code === 'TOKEN_CORRUPTED')
) {
results.push({ userId, companyId, status: 'expired', error: err.code })
continue
}
const felkod = err instanceof SkatteverketSkattekontoError ? err.felkod : null
console.error('[skattekonto-sync-cron] Sync failed', {
userId,
companyId,
message,
felkod,
})
results.push({ userId, companyId, status: 'error', error: message })
}
}
const synced = results.filter(r => r.status === 'synced').length
const skipped = results.filter(r => r.status === 'skipped_cooldown').length
const expired = results.filter(r => r.status === 'expired').length
const errors = results.filter(r => r.status === 'error').length
console.log(
`[skattekonto-sync-cron] Processed ${results.length}: ${synced} synced, ${skipped} cooldown, ${expired} expired, ${errors} errors`,
)
return NextResponse.json({
processed: results.length,
synced,
skipped,
expired,
errors,
results,
})
}
@@ -0,0 +1,191 @@
import { z } from 'zod'
import { createClient } from '@/lib/supabase/server'
import { NextResponse } from 'next/server'
import { ensureInitialized } from '@/lib/init'
import { validateBody, validateQuery } from '@/lib/api/validate'
import {
UpsertAbsenceDaySchema,
AbsenceRangeQuerySchema,
AbsenceTypeSchema,
} from '@/lib/api/schemas'
import { requireCompanyId } from '@/lib/company/context'
import { requireWritePermission } from '@/lib/auth/require-write'
const isoDate = z.string().regex(/^\d{4}-\d{2}-\d{2}$/)
ensureInitialized()
async function loadEmployee(
supabase: Awaited<ReturnType<typeof createClient>>,
employeeId: string,
companyId: string,
) {
const { data } = await supabase
.from('employees')
.select('id')
.eq('id', employeeId)
.eq('company_id', companyId)
.maybeSingle()
return data
}
export async function GET(
request: Request,
{ params }: { params: Promise<{ id: string }> },
) {
const { id: employeeId } = await params
const supabase = await createClient()
const { data: { user } } = await supabase.auth.getUser()
if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
const companyId = await requireCompanyId(supabase, user.id)
const employee = await loadEmployee(supabase, employeeId, companyId)
if (!employee) {
return NextResponse.json({ error: 'Anställd hittades inte' }, { status: 404 })
}
const query = validateQuery(request, AbsenceRangeQuerySchema)
if (!query.success) return query.response
const { data, error } = await supabase
.from('salary_absence_days')
.select('id, absence_date, absence_type, hours, notes, salary_run_employee_id, created_at, updated_at')
.eq('company_id', companyId)
.eq('employee_id', employeeId)
.gte('absence_date', query.data.from)
.lte('absence_date', query.data.to)
.order('absence_date', { ascending: true })
if (error) {
return NextResponse.json({ error: error.message }, { status: 500 })
}
return NextResponse.json({ data })
}
export async function POST(
request: Request,
{ params }: { params: Promise<{ id: string }> },
) {
const { id: employeeId } = await params
const supabase = await createClient()
const { data: { user } } = await supabase.auth.getUser()
if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
const writeCheck = await requireWritePermission(supabase, user.id)
if (!writeCheck.ok) return writeCheck.response
const companyId = await requireCompanyId(supabase, user.id)
const employee = await loadEmployee(supabase, employeeId, companyId)
if (!employee) {
return NextResponse.json({ error: 'Anställd hittades inte' }, { status: 404 })
}
const validation = await validateBody(request, UpsertAbsenceDaySchema)
if (!validation.success) return validation.response
const body = validation.data
// Upsert via DELETE+INSERT on the natural key (employee, date, type) so the
// notes/hours/run-link can be replaced cleanly. The unique index makes ON
// CONFLICT viable too, but Supabase's typed client doesn't expose
// onConflict for our composite key without a named constraint name —
// delete-then-insert keeps the pattern consistent with token-store.ts.
const { error: deleteError } = await supabase
.from('salary_absence_days')
.delete()
.eq('company_id', companyId)
.eq('employee_id', employeeId)
.eq('absence_date', body.absence_date)
.eq('absence_type', body.absence_type)
if (deleteError) {
return NextResponse.json({ error: deleteError.message }, { status: 500 })
}
const { data, error } = await supabase
.from('salary_absence_days')
.insert({
company_id: companyId,
employee_id: employeeId,
absence_date: body.absence_date,
absence_type: body.absence_type,
hours: body.hours,
notes: body.notes ?? null,
salary_run_employee_id: body.salary_run_employee_id ?? null,
})
.select()
.single()
if (error) {
return NextResponse.json({ error: error.message }, { status: 500 })
}
return NextResponse.json({ data }, { status: 201 })
}
// Two modes: ?date=YYYY-MM-DD&type=... (single row) or ?from=…&to=… (range).
// We don't reuse AbsenceRangeQuerySchema.partial() because Zod refuses
// `.partial()` on a schema with refinements (the from<=to check).
const DeleteQuerySchema = z.object({
from: isoDate.optional(),
to: isoDate.optional(),
date: isoDate.optional(),
type: AbsenceTypeSchema.optional(),
})
export async function DELETE(
request: Request,
{ params }: { params: Promise<{ id: string }> },
) {
const { id: employeeId } = await params
const supabase = await createClient()
const { data: { user } } = await supabase.auth.getUser()
if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
const writeCheck = await requireWritePermission(supabase, user.id)
if (!writeCheck.ok) return writeCheck.response
const companyId = await requireCompanyId(supabase, user.id)
const employee = await loadEmployee(supabase, employeeId, companyId)
if (!employee) {
return NextResponse.json({ error: 'Anställd hittades inte' }, { status: 404 })
}
const query = validateQuery(request, DeleteQuerySchema)
if (!query.success) return query.response
const { date, type, from, to } = query.data
// Two delete modes: a single (date, type) row, or a date range.
const hasSingle = !!date
const hasRange = !!from && !!to
if (!hasSingle && !hasRange) {
return NextResponse.json(
{ error: 'Ange antingen ?date=YYYY-MM-DD&type=... eller ?from=...&to=...' },
{ status: 400 },
)
}
let q = supabase
.from('salary_absence_days')
.delete()
.eq('company_id', companyId)
.eq('employee_id', employeeId)
if (hasSingle) {
q = q.eq('absence_date', date!)
if (type) q = q.eq('absence_type', type)
} else {
q = q.gte('absence_date', from!).lte('absence_date', to!)
if (type) q = q.eq('absence_type', type)
}
const { error } = await q
if (error) {
return NextResponse.json({ error: error.message }, { status: 500 })
}
return NextResponse.json({ data: { ok: true } })
}
+56 -4
View File
@@ -71,10 +71,12 @@ export async function GET(
.eq('id', user.id)
.single()
// Load employees with their data
// Load employees with their data. We need monthly_salary on the employee
// record to derive FK499 sjuklönekostnad from per-day records (the line
// item `amount` is the net deduction, not the sjuklön cost).
const { data: runEmployees } = await supabase
.from('salary_run_employees')
.select('*, employee:employees(personnummer, specification_number, f_skatt_status), line_items:salary_line_items(*)')
.select('*, employee:employees(personnummer, specification_number, f_skatt_status, monthly_salary), line_items:salary_line_items(*)')
.eq('salary_run_id', id)
if (!runEmployees || runEmployees.length === 0) {
@@ -94,6 +96,36 @@ export async function GET(
contactEmail: (settings?.email || profile?.email || user.email || '').trim(),
}
// Load per-day absence records for VAB + parental in the pay period.
// Sick days never reach AGI (they go to Försäkringskassan separately).
// The XML generator's Frånvarouppgift section consumes these per event.
const periodStart = `${run.period_year}-${String(run.period_month).padStart(2, '0')}-01`
const periodEndDate = new Date(Date.UTC(run.period_year, run.period_month, 0))
const periodEnd = periodEndDate.toISOString().slice(0, 10)
const employeeIds = runEmployees
.map(sre => sre.employee_id as string)
.filter(Boolean)
const absenceByEmployee = new Map<string, Array<{ date: string; type: 'vab' | 'parental'; hours: number }>>()
if (employeeIds.length > 0) {
const { data: absenceRows } = await supabase
.from('salary_absence_days')
.select('employee_id, absence_date, absence_type, hours')
.eq('company_id', companyId)
.in('absence_type', ['vab', 'parental'])
.gte('absence_date', periodStart)
.lte('absence_date', periodEnd)
.in('employee_id', employeeIds)
for (const row of (absenceRows ?? [])) {
const list = absenceByEmployee.get(row.employee_id) ?? []
list.push({
date: row.absence_date as string,
type: row.absence_type as 'vab' | 'parental',
hours: Number(row.hours ?? 8),
})
absenceByEmployee.set(row.employee_id, list)
}
}
const employeeData: AGIEmployeeData[] = runEmployees.map(sre => {
const emp = sre.employee as { personnummer: string; specification_number: number; f_skatt_status: string } | null
const lineItems = (sre.line_items || []) as Array<Record<string, unknown>>
@@ -104,6 +136,8 @@ export async function GET(
const benefitHousing = sumLineItemAmounts(lineItems, ['benefit_housing'])
const benefitOther = sumLineItemAmounts(lineItems, ['benefit_wellness', 'benefit_other'])
const absenceEvents = absenceByEmployee.get(sre.employee_id as string)
return {
personnummer: emp?.personnummer || '',
specificationNumber: emp?.specification_number || 0,
@@ -118,6 +152,7 @@ export async function GET(
sickDays: sre.sick_days > 0 ? sre.sick_days : undefined,
vabDays: sre.vab_days > 0 ? sre.vab_days : undefined,
parentalDays: sre.parental_days > 0 ? sre.parental_days : undefined,
absenceEvents: absenceEvents && absenceEvents.length > 0 ? absenceEvents : undefined,
}
})
@@ -140,15 +175,32 @@ export async function GET(
0
)
// FK499 TotalSjuklonekostnad — sum of sjuklön paid (days 2–14) across all
// FK499 TotalSjuklonekostnad — sum of *paid* sjuklön (days 2–14) across all
// employees. Day 1 is karens (unpaid); day 15+ is Försäkringskassan, so
// neither counts as an employer sjuklön cost.
//
// Sjuklön cost = dailyRate × sjuklonRate × day-2-14 count.
// The line item `amount` is the *net deduction* (lostPay − sjuklön), not
// the cost — using its quantity field plus the employee's monthly salary
// gives the correct sjuklön cost regardless of the line-item amount
// convention.
//
// sjuklonRate is read from the run's calculation_params snapshot (taken at
// calc time), so an operator override (e.g. for a CBA-specific rate) is
// honored. Falls back to 0.80 (Sjuklönelagen default) for older runs that
// don't have the snapshot.
const calcParams = (run.calculation_params ?? {}) as { sjuklonRate?: number; sjuklon_rate?: number }
const sjuklonRate = calcParams.sjuklonRate ?? calcParams.sjuklon_rate ?? 0.80
let totalSjuklonekostnad = 0
for (const sre of runEmployees) {
const monthly = (sre.employee as { monthly_salary?: number } | null)?.monthly_salary ?? 0
if (!monthly) continue
const dailyRate = monthly / 21
const lineItems = (sre.line_items || []) as Array<Record<string, unknown>>
for (const li of lineItems) {
if (li.item_type === 'sick_day2_14') {
totalSjuklonekostnad += Math.abs((li.amount as number) || 0)
const days = (li.quantity as number) || 0
totalSjuklonekostnad += dailyRate * sjuklonRate * days
}
}
}
+98 -18
View File
@@ -6,8 +6,18 @@ import { requireWritePermission } from '@/lib/auth/require-write'
import { calculateSalary } from '@/lib/salary/calculation-engine'
import { loadPayrollConfig, serializePayrollConfig } from '@/lib/salary/payroll-config'
import { fetchAllTaxTableRatesForRun, TaxTableUnavailableError } from '@/lib/salary/tax-tables'
import { loadAndDeriveAbsence } from '@/lib/salary/derive-absence-line-items'
import { getLineItemAccount } from '@/lib/salary/account-mapping'
import type { SalaryLineItemType } from '@/types'
const DERIVED_ABSENCE_TYPES: SalaryLineItemType[] = [
'sick_karens',
'sick_day2_14',
'sick_day15_plus',
'vab',
'parental_leave',
]
ensureInitialized()
export async function POST(
@@ -125,19 +135,91 @@ export async function POST(
ytdByEmployee.set(prior.employee_id, current)
}
// Pay period bounds — used to load per-day absence records.
const periodYear = run.period_year as number
const periodMonth = run.period_month as number
const periodStart = `${periodYear}-${String(periodMonth).padStart(2, '0')}-01`
const periodEndDate = new Date(Date.UTC(periodYear, periodMonth, 0)) // last day of month
const periodEnd = periodEndDate.toISOString().slice(0, 10)
for (const sre of runEmployees) {
const emp = sre.employee
if (!emp) continue
const lineItems = (sre.line_items || []).map((li: Record<string, unknown>) => ({
// ── Derive absence line items from per-day records ─────────────────
// Sjuklöneperiod boundaries, återinsjuknande, högriskskydd, day-15
// FK transition all require dates — we can't compute them from
// aggregated quantities. Replace any existing derived absence rows on
// this sre with the freshly-computed ones, then merge into the
// in-memory lineItems array passed to calculateSalary.
const absenceResult = await loadAndDeriveAbsence({
supabase,
companyId,
employeeId: emp.id,
monthlySalary: emp.monthly_salary || 0,
payrollConfig: config,
periodStart,
periodEnd,
})
const { error: delAbsErr } = await supabase
.from('salary_line_items')
.delete()
.eq('salary_run_employee_id', sre.id)
.in('item_type', DERIVED_ABSENCE_TYPES)
if (delAbsErr) {
return NextResponse.json({ error: delAbsErr.message }, { status: 500 })
}
if (absenceResult.lineItems.length > 0) {
const rows = absenceResult.lineItems.map((li, idx) => ({
salary_run_employee_id: sre.id,
company_id: companyId,
item_type: li.item_type,
description: li.description,
quantity: li.quantity,
amount: Math.round(li.amount * 100) / 100,
is_taxable: li.is_taxable,
is_avgift_basis: li.is_avgift_basis,
is_vacation_basis: li.is_vacation_basis,
is_gross_deduction: li.is_gross_deduction,
is_net_deduction: false,
account_number: getLineItemAccount(li.item_type),
sort_order: 100 + idx, // sort derived items after manual ones
}))
const { error: insAbsErr } = await supabase
.from('salary_line_items')
.insert(rows)
if (insAbsErr) {
return NextResponse.json({ error: insAbsErr.message }, { status: 500 })
}
}
// Build the merged in-memory line items: keep non-derived items from
// the originally-loaded sre.line_items, then append the freshly-derived
// absence items.
const manualLineItems = (sre.line_items || [])
.filter((li: Record<string, unknown>) =>
!DERIVED_ABSENCE_TYPES.includes(li.item_type as SalaryLineItemType))
.map((li: Record<string, unknown>) => ({
itemType: li.item_type as SalaryLineItemType,
amount: li.amount as number,
isTaxable: li.is_taxable as boolean,
isAvgiftBasis: li.is_avgift_basis as boolean,
isVacationBasis: li.is_vacation_basis as boolean,
isGrossDeduction: li.is_gross_deduction as boolean,
isNetDeduction: li.is_net_deduction as boolean,
}))
const derivedLineItems = absenceResult.lineItems.map(li => ({
itemType: li.item_type as SalaryLineItemType,
amount: li.amount as number,
isTaxable: li.is_taxable as boolean,
isAvgiftBasis: li.is_avgift_basis as boolean,
isVacationBasis: li.is_vacation_basis as boolean,
isGrossDeduction: li.is_gross_deduction as boolean,
isNetDeduction: li.is_net_deduction as boolean,
amount: li.amount,
isTaxable: li.is_taxable,
isAvgiftBasis: li.is_avgift_basis,
isVacationBasis: li.is_vacation_basis,
isGrossDeduction: li.is_gross_deduction,
isNetDeduction: false,
}))
const lineItems = [...manualLineItems, ...derivedLineItems]
const result = calculateSalary(
{
@@ -175,17 +257,15 @@ export async function POST(
}))
)
// Count absence days from line items
const rawLines = (sre.line_items || []) as Array<Record<string, unknown>>
function sumQuantity(types: string[]): number {
return rawLines
.filter(li => types.includes(li.item_type as string))
.reduce((sum: number, li) => sum + ((li.quantity as number) || 0), 0)
}
const sickDays = sumQuantity(['sick_karens', 'sick_day2_14'])
const vabDays = sumQuantity(['vab'])
const parentalDays = sumQuantity(['parental_leave'])
const vacationDays = sumQuantity(['vacation'])
// Aggregated absence counts derived from per-day records (above).
// Vacation still comes from line items because it's user-entered, not
// calendar-tracked yet.
const sickDays = absenceResult.aggregated.sickDays
const vabDays = absenceResult.aggregated.vabDays
const parentalDays = absenceResult.aggregated.parentalDays
const vacationDays = (sre.line_items || [])
.filter((li: Record<string, unknown>) => li.item_type === 'vacation')
.reduce((sum: number, li: Record<string, unknown>) => sum + ((li.quantity as number) || 0), 0)
// Update salary_run_employee with calculated results. If any individual
// update fails we abort so run totals aren't written from partial data.
@@ -6,6 +6,36 @@ import { requireWritePermission } from '@/lib/auth/require-write'
ensureInitialized()
/** Fetch one employee's pay spec within a salary run, with employee + line items. */
export async function GET(
request: Request,
{ params }: { params: Promise<{ id: string; employeeId: string }> },
) {
const { id, employeeId } = await params
const supabase = await createClient()
const { data: { user } } = await supabase.auth.getUser()
if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
const companyId = await requireCompanyId(supabase, user.id)
const { data, error } = await supabase
.from('salary_run_employees')
.select('*, employee:employees(*), line_items:salary_line_items(*)')
.eq('salary_run_id', id)
.eq('employee_id', employeeId)
.eq('company_id', companyId)
.maybeSingle()
if (error) {
return NextResponse.json({ error: error.message }, { status: 500 })
}
if (!data) {
return NextResponse.json({ error: 'Anställd hittades inte i lönekörningen' }, { status: 404 })
}
return NextResponse.json({ data })
}
/** Remove employee from a draft salary run. Cascades to delete their line items. */
export async function DELETE(
request: Request,
+19
View File
@@ -3,6 +3,7 @@ import { NextResponse } from 'next/server'
import { ensureInitialized } from '@/lib/init'
import { requireCompanyId } from '@/lib/company/context'
import { requireWritePermission } from '@/lib/auth/require-write'
import { formatRedovisare } from '@/lib/skatteverket/format'
ensureInitialized()
@@ -35,9 +36,27 @@ export async function GET(
.eq('salary_run_id', id)
.order('created_at')
// Resolve Skatteverket arbetsgivare ID for AGI submission. We surface this
// in the run payload so the client doesn't need a second round-trip just to
// build extension URLs. Quietly null when the org number isn't set yet.
let arbetsgivare: string | null = null
const { data: settings } = await supabase
.from('company_settings')
.select('org_number, entity_type')
.eq('company_id', companyId)
.maybeSingle()
if (settings?.org_number && settings?.entity_type) {
try {
arbetsgivare = formatRedovisare(settings.org_number, settings.entity_type)
} catch {
arbetsgivare = null
}
}
return NextResponse.json({
data: {
...run,
arbetsgivare,
employees: (employees || []).map(emp => ({
...emp,
employee: emp.employee ? {