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
@@ -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 ? {
|
||||
|
||||
Reference in New Issue
Block a user