feat(mileage): körjournal with milersättning booking, MCP tools and CSV export (#1448)
* feat(mileage): körjournal with milersättning booking, MCP tools and CSV export New mileage_trips table (RLS, booked-delete trigger per BFL retention), lib/mileage service reusing the payroll schablon rates, /api/mileage routes (trips CRUD, period booking to 7331, salary-run push, körjournal CSV), Körjournal dashboard page + nav, and three staged MCP tools (search-only catalog). Trips book as one verifikat per period via the engine; salary path inserts mileage_taxfree line items. mileage_trips classified in the full-archive export. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * refactor(mileage): use shared roundOre helper per tightened ratchet baseline Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(mileage): pending_operations op-type migration + Swedish review findings - New migration pair adds log_mileage_trip/book_mileage_period to the pending_operations operation_type CHECK (pg-real audit). - bookMileagePeriod refuses a period spanning several employees and names the employee in the verifikationstext when scoped (BFL motpart). - vehicle_registration required for förmånsbil trips (schema, service, MCP staging, UI surfaces the field). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(mileage): claim-first booking, CSV injection guard and driver column - bookMileagePeriod claims trips (draft to booked CAS) before creating the verifikat, so a concurrent second booking loses the race instead of double-booking; claim reverts if verifikat creation fails. - Körjournal CSV neutralizes formula-injection triggers (OWASP) and adds a Förare column naming the employee per trip. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(mileage): resolve CodeRabbit + Swedish review round: race, drift and hardening - Copying a round trip no longer re-doubles the stored distance. - pushMileageToSalaryRun claims trips before inserting line items (retry can no longer double-pay); CLAIM_LOST replaces misleading NO_TRIPS on lost races. - Booked trips are DB-immutable via a BEFORE UPDATE trigger (new migration 20260807113215): only claim/link/revert transitions and notes edits pass. - Cross-year periods rejected (schablon rates are per calendar year); payroll config year read from the date string, not TZ-dependent getFullYear(). - MCP staged bookings freeze the previewed trip set (trip_ids in params) and the commit fails on drift; validation errors return 400, not 500. - PATCH enforces the förmånsbil regnr rule on the effective row; export validates dates before they reach the Content-Disposition header; employee_id is verified company-scoped on trip creation; stale orphaned claims released. - UI: fetch flags reset in finally; ICU plural for draft summary; distance stored at the column's 1-decimal precision. - Tests: [id] route suite, pushMileageToSalaryRun suite, claim-race, drift, cross-year and update-trigger pg cases. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(mileage): revert-to-draft must clear salary_run_id at the trigger level New migration 20260807114924 replaces the booked-immutability function: a booked -> draft revert now rejects rows keeping salary_run_id, closing the DB-level double-pay path CodeRabbit flagged. pg test pins both directions; the CLAIM_LOST unit test now asserts the revert. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(mileage): company-scope employee_id on PATCH (Superagent P2) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * test(mileage): valid v4 uuid in cross-company employee PATCH test Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5
parent
f7f3a31f8e
commit
7411a0171b
@@ -818,6 +818,9 @@ One line per decision: `[YYYY-MM-DD] <decision>: <why>`. Appended by agents and
|
||||
[2026-08-06] WINT provider built Tier A only (partner-facing v1 endpoints, SIE rendered by our own sie-builder from /api/Voucher + /api/Account): their native SIE export and IncomingInvoice live in the internal Full spec and are deliberately unused until WINT grants partner access. Ships dark behind WINT_MIGRATION_ENABLED. Auth is credential pass-through (mail+password exchanged once for a JWT pair; password never stored) because WINT has no OAuth or API keys.
|
||||
[2026-08-06] Login credentials error says "Fel e-postadress eller lösenord", not "Fel lösenord": GoTrue returns one invalid_credentials code for unknown-email and wrong-password alike (anti-enumeration), so a "wrong password" claim would be both unknowable and an account-existence leak. Clarity comes from inline placement + reset link instead.
|
||||
[2026-08-06] Empty SIE file (0 parsed vouchers AND no raw #VER declaration) finalizes as completed no-op, not failed: Fortnox exports an empty file for a not-yet-booked fiscal year and failing it aborted the whole migration wizard (CashLeads case). The failed-downgrade now fires only when the file contained vouchers that could not be imported; the raw-content #VER cross-check must stay, since a separator/encoding mismatch can swallow every #VER block with only a warning-severity parse issue and would otherwise masquerade as a legitimate empty year. The balance-only continuation-guard scenario rides along as no-op since re-running the same file can never produce a different outcome.
|
||||
[2026-08-07] Körjournal MCP tools ship search-only (catalogVisibility 'search'): the three tools crossed the tools/list 59K context ceiling, and the payload guard's stated preference is search-only over bumping. Agents reach them via gnubok_search_tools ("körjournal", "milersättning", "mileage").
|
||||
[2026-08-07] book_mileage_period risk tier is 'medium', not 'high' like create_voucher: the verifikat lines are fixed (7331 debit at the DB-configured schablon + a whitelisted counter account), not caller-supplied arbitrary lines; same tier rationale as post_annual_depreciation. Default counter account is 2820 (skuld till anställda), with 2893/1930 as explicit choices.
|
||||
[2026-08-07] mileage_trips.status has no per-trip storno flow: a booked trip is underlag frozen by trigger; correcting a wrong booked period goes through reversing the verifikat (existing storno paths), not through editing trips. Keeps the trip log append-only like a paper körjournal.
|
||||
[2026-08-06] Bucket A defaults pass commits the /pending Godkänn pill directly for low/medium risk and keeps the ConfirmationDialog only for high risk: the Granskning row already states source, title, risk and offers Detaljer, so the dialog's second Godkänn restated the row (the audit's expert lens called double-Godkänn the thing professionals do not tolerate). The chat-side "Godkänn alla N" was DEFERRED, not built: ApprovalCard owns its whole state machine internally (commit fetch, account-activation retry, typed high-risk confirm) and a bulk commit from AgentChat would leave committed cards rendering as pending; that is assistant-redesign seam 8.8 (approval batching) and needs the state lifted, not a button.
|
||||
[2026-08-06] SIE-export period default left unchanged despite the choice-audit finding: FiscalYearSelector with includeAllOption=false already auto-selects the newest started period once loaded, so the "opens with nothing selected" claim is only fetch latency. FyPicker gained preferLatestEnded for helårsmoms instead, which also skips the shared per-company localStorage scope on that surface: a filing page defaulting to the current (unfilable) year because Balansräkningen was last viewed there is the one wrong default.
|
||||
[2026-08-06] Review-workflow triage on the Bucket A branch (13 confirmed findings): fixed 10, incl. the branch-killing one (setActiveCompany's cookie write throws in Server Component render, so the /select-company auto-forward silently never fired: the cookie set is now best-effort because the gnubok-company-id cookie is write-only compat nothing reads). Batch "Ingen moms" now goes over the wire as 'exempt' instead of collapsing to undefined, which had an explicit no-VAT choice booking the derived 25%; the same pre-existing collapse in QuickReviewDialog/CategoryExpandedDialog is left for a follow-up. Skipped by choice: generalizing AiFilledIndicator for history provenance (the note's copy already names the source) and converting BulkBookInboxDialog's hardcoded-Swedish option lists to i18n (whole-file migration, not this branch's divergence). Monthly momsdeklaration default is deadline-aware (M-2 until the 12th/17th, M-1 after; over-40M always M-1) mirroring deadline-config, not just calendar-ended.
|
||||
|
||||
@@ -0,0 +1,649 @@
|
||||
'use client'
|
||||
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import { useTranslations } from 'next-intl'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Checkbox } from '@/components/ui/checkbox'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import { TH_CLASS, TD_CLASS, HOVER_REVEAL_CLASS } from '@/components/ui/dry-table'
|
||||
import { useToast } from '@/components/ui/use-toast'
|
||||
import { EmptyState } from '@/components/ui/empty-state'
|
||||
import { ContextPicker } from '@/components/common/ContextPicker'
|
||||
import { PageHeader } from '@/components/ui/page-header'
|
||||
import { useCanWrite } from '@/lib/hooks/use-can-write'
|
||||
import { formatCurrency, formatDate } from '@/lib/utils'
|
||||
import { Car, Copy, Download, Plus, Trash2, Pencil, ChevronDown, ChevronUp } from 'lucide-react'
|
||||
import type { MileageTrip, MileageVehicleType } from '@/types'
|
||||
|
||||
interface TripFormState {
|
||||
trip_date: string
|
||||
vehicle_type: MileageVehicleType
|
||||
vehicle_registration: string
|
||||
odometer_start: string
|
||||
odometer_end: string
|
||||
distance_km: string
|
||||
from_location: string
|
||||
to_location: string
|
||||
purpose: string
|
||||
visited: string
|
||||
is_round_trip: boolean
|
||||
notes: string
|
||||
}
|
||||
|
||||
function today(): string {
|
||||
return new Date().toISOString().slice(0, 10)
|
||||
}
|
||||
|
||||
function monthBounds(month: string): { from: string; to: string } {
|
||||
const [y, m] = month.split('-').map(Number)
|
||||
const last = new Date(Date.UTC(y, m, 0)).getUTCDate()
|
||||
return { from: `${month}-01`, to: `${month}-${String(last).padStart(2, '0')}` }
|
||||
}
|
||||
|
||||
function emptyForm(): TripFormState {
|
||||
return {
|
||||
trip_date: today(),
|
||||
vehicle_type: 'own_car',
|
||||
vehicle_registration: '',
|
||||
odometer_start: '',
|
||||
odometer_end: '',
|
||||
distance_km: '',
|
||||
from_location: '',
|
||||
to_location: '',
|
||||
purpose: '',
|
||||
visited: '',
|
||||
is_round_trip: false,
|
||||
notes: '',
|
||||
}
|
||||
}
|
||||
|
||||
function formFromTrip(trip: MileageTrip, keepDate: boolean): TripFormState {
|
||||
// The create form takes ONE-WAY km with a round-trip toggle that doubles on
|
||||
// save. The stored distance is always the full logged distance, so a copy
|
||||
// of a round trip must present the halved (one-way) value or saving would
|
||||
// double it again. Edit mode (keepDate) shows the stored total and never
|
||||
// re-doubles.
|
||||
const displayKm =
|
||||
!keepDate && trip.is_round_trip ? Number(trip.distance_km) / 2 : Number(trip.distance_km)
|
||||
return {
|
||||
trip_date: keepDate ? trip.trip_date : today(),
|
||||
vehicle_type: trip.vehicle_type,
|
||||
vehicle_registration: trip.vehicle_registration ?? '',
|
||||
odometer_start: keepDate && trip.odometer_start != null ? String(trip.odometer_start) : '',
|
||||
odometer_end: keepDate && trip.odometer_end != null ? String(trip.odometer_end) : '',
|
||||
distance_km: String(displayKm),
|
||||
from_location: trip.from_location,
|
||||
to_location: trip.to_location,
|
||||
purpose: trip.purpose,
|
||||
visited: trip.visited ?? '',
|
||||
is_round_trip: trip.is_round_trip,
|
||||
notes: trip.notes ?? '',
|
||||
}
|
||||
}
|
||||
|
||||
export default function MileagePage() {
|
||||
const t = useTranslations('mileage')
|
||||
const { toast } = useToast()
|
||||
const { canWrite } = useCanWrite()
|
||||
|
||||
const [trips, setTrips] = useState<MileageTrip[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [month, setMonth] = useState<string>('all')
|
||||
|
||||
const [formOpen, setFormOpen] = useState(false)
|
||||
const [editingId, setEditingId] = useState<string | null>(null)
|
||||
const [form, setForm] = useState<TripFormState>(emptyForm())
|
||||
const [showMore, setShowMore] = useState(false)
|
||||
const [saving, setSaving] = useState(false)
|
||||
|
||||
const [bookOpen, setBookOpen] = useState(false)
|
||||
const [bookFrom, setBookFrom] = useState('')
|
||||
const [bookTo, setBookTo] = useState('')
|
||||
const [bookEntryDate, setBookEntryDate] = useState(today())
|
||||
const [counterAccount, setCounterAccount] = useState<'2820' | '2893' | '1930'>('2820')
|
||||
const [booking, setBooking] = useState(false)
|
||||
|
||||
const loadTrips = useCallback(async () => {
|
||||
try {
|
||||
const res = await fetch('/api/mileage/trips')
|
||||
if (!res.ok) {
|
||||
toast({ title: t('load_error'), variant: 'destructive' })
|
||||
return
|
||||
}
|
||||
const body = await res.json()
|
||||
setTrips(body.data || [])
|
||||
} catch {
|
||||
toast({ title: t('load_error'), variant: 'destructive' })
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [t, toast])
|
||||
|
||||
useEffect(() => {
|
||||
loadTrips()
|
||||
}, [loadTrips])
|
||||
|
||||
const months = useMemo(() => {
|
||||
const set = new Set(trips.map((trip) => trip.trip_date.slice(0, 7)))
|
||||
set.add(today().slice(0, 7))
|
||||
return [...set].sort().reverse()
|
||||
}, [trips])
|
||||
|
||||
const visibleTrips = useMemo(
|
||||
() => (month === 'all' ? trips : trips.filter((trip) => trip.trip_date.startsWith(month))),
|
||||
[trips, month]
|
||||
)
|
||||
|
||||
const draftTrips = useMemo(
|
||||
() => visibleTrips.filter((trip) => trip.status === 'draft'),
|
||||
[visibleTrips]
|
||||
)
|
||||
const draftKm = useMemo(
|
||||
() => Math.round(draftTrips.reduce((sum, trip) => sum + Number(trip.distance_km), 0) * 10) / 10,
|
||||
[draftTrips]
|
||||
)
|
||||
|
||||
const openCreate = () => {
|
||||
setEditingId(null)
|
||||
setForm(emptyForm())
|
||||
setShowMore(false)
|
||||
setFormOpen(true)
|
||||
}
|
||||
|
||||
const openEdit = (trip: MileageTrip) => {
|
||||
setEditingId(trip.id)
|
||||
setForm(formFromTrip(trip, true))
|
||||
setShowMore(Boolean(trip.vehicle_registration || trip.odometer_start || trip.visited || trip.notes))
|
||||
setFormOpen(true)
|
||||
}
|
||||
|
||||
const openCopy = (trip: MileageTrip) => {
|
||||
setEditingId(null)
|
||||
setForm(formFromTrip(trip, false))
|
||||
setShowMore(false)
|
||||
setFormOpen(true)
|
||||
}
|
||||
|
||||
const submitForm = async () => {
|
||||
const km = Number(form.distance_km.replace(',', '.'))
|
||||
if (!(km > 0) || !form.from_location.trim() || !form.to_location.trim() || !form.purpose.trim()) {
|
||||
toast({ title: t('form_incomplete'), variant: 'destructive' })
|
||||
return
|
||||
}
|
||||
setSaving(true)
|
||||
try {
|
||||
const payload = {
|
||||
trip_date: form.trip_date,
|
||||
vehicle_type: form.vehicle_type,
|
||||
vehicle_registration: form.vehicle_registration.trim() || null,
|
||||
odometer_start: form.odometer_start ? Number(form.odometer_start) : null,
|
||||
odometer_end: form.odometer_end ? Number(form.odometer_end) : null,
|
||||
// The round-trip toggle doubles the one-way distance on save; the stored
|
||||
// km always covers the full logged distance.
|
||||
distance_km: editingId ? km : form.is_round_trip ? km * 2 : km,
|
||||
from_location: form.from_location.trim(),
|
||||
to_location: form.to_location.trim(),
|
||||
purpose: form.purpose.trim(),
|
||||
visited: form.visited.trim() || null,
|
||||
is_round_trip: form.is_round_trip,
|
||||
notes: form.notes.trim() || null,
|
||||
}
|
||||
const res = await fetch(editingId ? `/api/mileage/trips/${editingId}` : '/api/mileage/trips', {
|
||||
method: editingId ? 'PATCH' : 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload),
|
||||
})
|
||||
if (!res.ok) {
|
||||
const body = await res.json().catch(() => null)
|
||||
const message = typeof body?.error === 'string' ? body.error : body?.error?.message
|
||||
toast({ title: message || t('save_error'), variant: 'destructive' })
|
||||
return
|
||||
}
|
||||
setFormOpen(false)
|
||||
toast({ title: editingId ? t('trip_updated') : t('trip_saved') })
|
||||
await loadTrips()
|
||||
} catch {
|
||||
toast({ title: t('save_error'), variant: 'destructive' })
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
const deleteTrip = async (trip: MileageTrip) => {
|
||||
const res = await fetch(`/api/mileage/trips/${trip.id}`, { method: 'DELETE' })
|
||||
if (!res.ok) {
|
||||
toast({ title: t('delete_error'), variant: 'destructive' })
|
||||
return
|
||||
}
|
||||
toast({ title: t('trip_deleted') })
|
||||
await loadTrips()
|
||||
}
|
||||
|
||||
const openBook = () => {
|
||||
const base = month === 'all' ? today().slice(0, 7) : month
|
||||
const bounds = monthBounds(base)
|
||||
setBookFrom(bounds.from)
|
||||
setBookTo(bounds.to)
|
||||
setBookEntryDate(bounds.to <= today() ? bounds.to : today())
|
||||
setBookOpen(true)
|
||||
}
|
||||
|
||||
const submitBook = async () => {
|
||||
setBooking(true)
|
||||
try {
|
||||
const res = await fetch('/api/mileage/book', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
from: bookFrom,
|
||||
to: bookTo,
|
||||
entry_date: bookEntryDate,
|
||||
counter_account: counterAccount,
|
||||
}),
|
||||
})
|
||||
const body = await res.json().catch(() => null)
|
||||
if (!res.ok) {
|
||||
const message = typeof body?.error === 'string' ? body.error : body?.error?.message
|
||||
toast({ title: message || t('book_error'), variant: 'destructive' })
|
||||
return
|
||||
}
|
||||
setBookOpen(false)
|
||||
toast({
|
||||
title: t('booked_title', {
|
||||
voucher: `${body.data.voucher_series ?? ''}${body.data.voucher_number ?? ''}`,
|
||||
}),
|
||||
description: t('booked_description', {
|
||||
count: body.data.trip_count,
|
||||
amount: formatCurrency(body.data.total_amount),
|
||||
}),
|
||||
})
|
||||
await loadTrips()
|
||||
} catch {
|
||||
toast({ title: t('book_error'), variant: 'destructive' })
|
||||
} finally {
|
||||
setBooking(false)
|
||||
}
|
||||
}
|
||||
|
||||
const exportHref = useMemo(() => {
|
||||
if (month === 'all') return '/api/mileage/export'
|
||||
const bounds = monthBounds(month)
|
||||
return `/api/mileage/export?from=${bounds.from}&to=${bounds.to}`
|
||||
}, [month])
|
||||
|
||||
const monthLabel = month === 'all' ? t('all_months') : month
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
<PageHeader
|
||||
title={t('title')}
|
||||
action={
|
||||
canWrite ? (
|
||||
<Button onClick={openCreate}>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
{t('new_trip')}
|
||||
</Button>
|
||||
) : undefined
|
||||
}
|
||||
/>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
{draftTrips.length > 0 && (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t('draft_summary', { count: draftTrips.length, km: draftKm })}
|
||||
</p>
|
||||
)}
|
||||
<div className="ml-auto flex items-center gap-2">
|
||||
{canWrite && draftTrips.length > 0 && (
|
||||
<Button variant="secondary" onClick={openBook}>
|
||||
{t('book_period')}
|
||||
</Button>
|
||||
)}
|
||||
{trips.length > 0 && (
|
||||
<Button variant="ghost" asChild>
|
||||
<a href={exportHref} download>
|
||||
<Download className="mr-2 h-4 w-4" />
|
||||
{t('export_csv')}
|
||||
</a>
|
||||
</Button>
|
||||
)}
|
||||
<ContextPicker
|
||||
items={[
|
||||
{ id: 'all', label: t('all_months') },
|
||||
...months.map((m) => ({ id: m, label: m })),
|
||||
]}
|
||||
value={month}
|
||||
onChange={setMonth}
|
||||
triggerLabel={monthLabel}
|
||||
ariaLabel={t('month_filter')}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div className="space-y-2" role="status">
|
||||
<Skeleton className="h-10 w-full" />
|
||||
<Skeleton className="h-10 w-full" />
|
||||
<Skeleton className="h-10 w-full" />
|
||||
</div>
|
||||
) : visibleTrips.length === 0 ? (
|
||||
<EmptyState
|
||||
icon={Car}
|
||||
title={t('empty_title')}
|
||||
description={t('empty_description')}
|
||||
actionLabel={canWrite ? t('new_trip') : undefined}
|
||||
onAction={canWrite ? openCreate : undefined}
|
||||
/>
|
||||
) : (
|
||||
<table className="w-full border-collapse text-[13px]">
|
||||
<thead>
|
||||
<tr>
|
||||
<th className={TH_CLASS}>{t('col_date')}</th>
|
||||
<th className={TH_CLASS}>{t('col_route')}</th>
|
||||
<th className={TH_CLASS}>{t('col_purpose')}</th>
|
||||
<th className={`${TH_CLASS} text-right`}>{t('col_km')}</th>
|
||||
<th className={TH_CLASS}>{t('col_status')}</th>
|
||||
<th className={TH_CLASS} />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="stagger-enter">
|
||||
{visibleTrips.map((trip) => (
|
||||
<tr key={trip.id} className="group hover:bg-secondary/35">
|
||||
<td className={`${TD_CLASS} tabular-nums whitespace-nowrap`}>
|
||||
{formatDate(trip.trip_date)}
|
||||
</td>
|
||||
<td className={TD_CLASS}>
|
||||
{trip.from_location} – {trip.to_location}
|
||||
{trip.is_round_trip ? ` ${t('round_trip_suffix')}` : ''}
|
||||
</td>
|
||||
<td className={`${TD_CLASS} text-muted-foreground`}>{trip.purpose}</td>
|
||||
<td className={`${TD_CLASS} text-right tabular-nums`}>
|
||||
{Number(trip.distance_km).toLocaleString('sv-SE')}
|
||||
</td>
|
||||
<td className={TD_CLASS}>
|
||||
{trip.status === 'draft' ? (
|
||||
<Badge variant="outline">{t('status_draft')}</Badge>
|
||||
) : (
|
||||
<span className="text-xs text-muted-foreground">{t('status_booked')}</span>
|
||||
)}
|
||||
</td>
|
||||
<td className={`${TD_CLASS} text-right whitespace-nowrap`}>
|
||||
{canWrite && (
|
||||
<span className={HOVER_REVEAL_CLASS}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
aria-label={t('copy_trip')}
|
||||
onClick={() => openCopy(trip)}
|
||||
>
|
||||
<Copy className="h-4 w-4" />
|
||||
</Button>
|
||||
{trip.status === 'draft' && (
|
||||
<>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
aria-label={t('edit_trip')}
|
||||
onClick={() => openEdit(trip)}
|
||||
>
|
||||
<Pencil className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
aria-label={t('delete_trip')}
|
||||
onClick={() => deleteTrip(trip)}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
|
||||
<Dialog open={formOpen} onOpenChange={setFormOpen}>
|
||||
<DialogContent className="sm:max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{editingId ? t('edit_trip') : t('new_trip')}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="trip_date">{t('field_date')}</Label>
|
||||
<Input
|
||||
id="trip_date"
|
||||
type="date"
|
||||
value={form.trip_date}
|
||||
onChange={(e) => setForm({ ...form, trip_date: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="vehicle_type">{t('field_vehicle')}</Label>
|
||||
<Select
|
||||
value={form.vehicle_type}
|
||||
onValueChange={(v) => {
|
||||
setForm({ ...form, vehicle_type: v as MileageVehicleType })
|
||||
// Regnr is required for a förmånsbil; surface the field.
|
||||
if (v !== 'own_car') setShowMore(true)
|
||||
}}
|
||||
>
|
||||
<SelectTrigger id="vehicle_type">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="own_car">{t('vehicle_own_car')}</SelectItem>
|
||||
<SelectItem value="company_car_fossil">{t('vehicle_company_fossil')}</SelectItem>
|
||||
<SelectItem value="company_car_electric">{t('vehicle_company_electric')}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="from_location">{t('field_from')}</Label>
|
||||
<Input
|
||||
id="from_location"
|
||||
value={form.from_location}
|
||||
onChange={(e) => setForm({ ...form, from_location: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="to_location">{t('field_to')}</Label>
|
||||
<Input
|
||||
id="to_location"
|
||||
value={form.to_location}
|
||||
onChange={(e) => setForm({ ...form, to_location: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="purpose">{t('field_purpose')}</Label>
|
||||
<Input
|
||||
id="purpose"
|
||||
value={form.purpose}
|
||||
placeholder={t('purpose_placeholder')}
|
||||
onChange={(e) => setForm({ ...form, purpose: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="distance_km">
|
||||
{editingId ? t('field_km_total') : t('field_km')}
|
||||
</Label>
|
||||
<Input
|
||||
id="distance_km"
|
||||
inputMode="decimal"
|
||||
value={form.distance_km}
|
||||
onChange={(e) => setForm({ ...form, distance_km: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
{!editingId && (
|
||||
<div className="flex items-end pb-2">
|
||||
<label className="flex items-center gap-2 text-sm">
|
||||
<Checkbox
|
||||
checked={form.is_round_trip}
|
||||
onCheckedChange={(v) => setForm({ ...form, is_round_trip: v === true })}
|
||||
/>
|
||||
{t('field_round_trip')}
|
||||
</label>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className="flex items-center gap-1 text-sm text-muted-foreground hover:text-foreground transition-colors duration-150"
|
||||
onClick={() => setShowMore(!showMore)}
|
||||
>
|
||||
{showMore ? <ChevronUp className="h-4 w-4" /> : <ChevronDown className="h-4 w-4" />}
|
||||
{t('more_fields')}
|
||||
</button>
|
||||
{showMore && (
|
||||
<div className="space-y-4">
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="vehicle_registration">{t('field_regnr')}</Label>
|
||||
<Input
|
||||
id="vehicle_registration"
|
||||
value={form.vehicle_registration}
|
||||
onChange={(e) => setForm({ ...form, vehicle_registration: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="odometer_start">{t('field_odometer_start')}</Label>
|
||||
<Input
|
||||
id="odometer_start"
|
||||
inputMode="numeric"
|
||||
value={form.odometer_start}
|
||||
onChange={(e) => setForm({ ...form, odometer_start: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="odometer_end">{t('field_odometer_end')}</Label>
|
||||
<Input
|
||||
id="odometer_end"
|
||||
inputMode="numeric"
|
||||
value={form.odometer_end}
|
||||
onChange={(e) => setForm({ ...form, odometer_end: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="visited">{t('field_visited')}</Label>
|
||||
<Input
|
||||
id="visited"
|
||||
value={form.visited}
|
||||
onChange={(e) => setForm({ ...form, visited: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="notes">{t('field_notes')}</Label>
|
||||
<Input
|
||||
id="notes"
|
||||
value={form.notes}
|
||||
onChange={(e) => setForm({ ...form, notes: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex justify-end gap-2 pt-2">
|
||||
<Button variant="ghost" onClick={() => setFormOpen(false)}>
|
||||
{t('cancel')}
|
||||
</Button>
|
||||
<Button onClick={submitForm} disabled={saving}>
|
||||
{saving ? t('saving') : t('save_trip')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<Dialog open={bookOpen} onOpenChange={setBookOpen}>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t('book_period')}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="book_from">{t('field_period_from')}</Label>
|
||||
<Input
|
||||
id="book_from"
|
||||
type="date"
|
||||
value={bookFrom}
|
||||
onChange={(e) => setBookFrom(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="book_to">{t('field_period_to')}</Label>
|
||||
<Input
|
||||
id="book_to"
|
||||
type="date"
|
||||
value={bookTo}
|
||||
onChange={(e) => setBookTo(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="book_entry_date">{t('field_entry_date')}</Label>
|
||||
<Input
|
||||
id="book_entry_date"
|
||||
type="date"
|
||||
value={bookEntryDate}
|
||||
onChange={(e) => setBookEntryDate(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="counter_account">{t('field_counter_account')}</Label>
|
||||
<Select
|
||||
value={counterAccount}
|
||||
onValueChange={(v) => setCounterAccount(v as '2820' | '2893' | '1930')}
|
||||
>
|
||||
<SelectTrigger id="counter_account">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="2820">{t('counter_2820')}</SelectItem>
|
||||
<SelectItem value="2893">{t('counter_2893')}</SelectItem>
|
||||
<SelectItem value="1930">{t('counter_1930')}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">{t('book_explainer')}</p>
|
||||
<div className="flex justify-end gap-2 pt-2">
|
||||
<Button variant="ghost" onClick={() => setBookOpen(false)}>
|
||||
{t('cancel')}
|
||||
</Button>
|
||||
<Button onClick={submitBook} disabled={booking}>
|
||||
{booking ? t('booking') : t('confirm_book')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { NextResponse } from 'next/server'
|
||||
|
||||
vi.mock('@/lib/init', () => ({ ensureInitialized: vi.fn() }))
|
||||
vi.mock('@/lib/auth/require-auth', () => ({ requireAuth: vi.fn() }))
|
||||
vi.mock('@/lib/company/context', () => ({
|
||||
getActiveCompanyId: vi.fn().mockResolvedValue('company-1'),
|
||||
requireCompanyId: vi.fn().mockResolvedValue('company-1'),
|
||||
}))
|
||||
vi.mock('@/lib/auth/require-write', () => ({
|
||||
requireWritePermission: vi.fn().mockResolvedValue({ ok: true }),
|
||||
}))
|
||||
vi.mock('@/lib/mileage/mileage-service', () => ({
|
||||
bookMileagePeriod: vi.fn(),
|
||||
}))
|
||||
|
||||
import { POST } from '../route'
|
||||
import { requireAuth } from '@/lib/auth/require-auth'
|
||||
import { bookMileagePeriod } from '@/lib/mileage/mileage-service'
|
||||
|
||||
const params = { params: Promise.resolve({}) } as never
|
||||
|
||||
function authed() {
|
||||
vi.mocked(requireAuth).mockResolvedValue({
|
||||
user: { id: 'user-1' } as never,
|
||||
supabase: {} as never,
|
||||
error: null,
|
||||
} as never)
|
||||
}
|
||||
|
||||
const VALID_BODY = {
|
||||
from: '2026-05-01',
|
||||
to: '2026-05-31',
|
||||
entry_date: '2026-05-31',
|
||||
counter_account: '2820',
|
||||
}
|
||||
|
||||
function postReq(body: unknown) {
|
||||
return new Request('https://x.test/api/mileage/book', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
})
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
describe('POST /api/mileage/book', () => {
|
||||
it('returns 401 when unauthenticated', async () => {
|
||||
vi.mocked(requireAuth).mockResolvedValue({
|
||||
user: null,
|
||||
supabase: null,
|
||||
error: NextResponse.json({ error: 'Unauthorized' }, { status: 401 }),
|
||||
} as never)
|
||||
const res = await POST(postReq(VALID_BODY), params)
|
||||
expect(res.status).toBe(401)
|
||||
})
|
||||
|
||||
it('returns 400 on an inverted date range', async () => {
|
||||
authed()
|
||||
const res = await POST(postReq({ ...VALID_BODY, from: '2026-06-01' }), params)
|
||||
expect(res.status).toBe(400)
|
||||
expect(bookMileagePeriod).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('returns 400 when the period has no unbooked trips', async () => {
|
||||
authed()
|
||||
vi.mocked(bookMileagePeriod).mockResolvedValue({ ok: false, code: 'NO_TRIPS' })
|
||||
const res = await POST(postReq(VALID_BODY), params)
|
||||
expect(res.status).toBe(400)
|
||||
})
|
||||
|
||||
it('returns 400 for a period spanning calendar years', async () => {
|
||||
authed()
|
||||
const res = await POST(
|
||||
postReq({ ...VALID_BODY, from: '2025-12-20', to: '2026-01-10', entry_date: '2026-01-10' }),
|
||||
params
|
||||
)
|
||||
expect(res.status).toBe(400)
|
||||
expect(bookMileagePeriod).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('returns 409 when a concurrent booking claimed the trips first', async () => {
|
||||
authed()
|
||||
vi.mocked(bookMileagePeriod).mockResolvedValue({ ok: false, code: 'CLAIM_LOST' })
|
||||
const res = await POST(postReq(VALID_BODY), params)
|
||||
expect(res.status).toBe(409)
|
||||
})
|
||||
|
||||
it('returns 400 when the period spans several employees', async () => {
|
||||
authed()
|
||||
vi.mocked(bookMileagePeriod).mockResolvedValue({ ok: false, code: 'MIXED_EMPLOYEES' })
|
||||
const res = await POST(postReq(VALID_BODY), params)
|
||||
expect(res.status).toBe(400)
|
||||
const body = await res.json()
|
||||
expect(body.error).toContain('per anställd')
|
||||
})
|
||||
|
||||
it('returns 400 when the entry date is in a locked period', async () => {
|
||||
authed()
|
||||
vi.mocked(bookMileagePeriod).mockResolvedValue({ ok: false, code: 'PERIOD_NOT_OPEN' })
|
||||
const res = await POST(postReq(VALID_BODY), params)
|
||||
expect(res.status).toBe(400)
|
||||
})
|
||||
|
||||
it('returns the verifikat summary on success', async () => {
|
||||
authed()
|
||||
vi.mocked(bookMileagePeriod).mockResolvedValue({
|
||||
ok: true,
|
||||
journalEntryId: 'je-1',
|
||||
voucherNumber: 42,
|
||||
voucherSeries: 'A',
|
||||
tripCount: 3,
|
||||
totalAmount: 297.5,
|
||||
summaries: [],
|
||||
})
|
||||
const res = await POST(postReq(VALID_BODY), params)
|
||||
expect(res.status).toBe(200)
|
||||
const body = await res.json()
|
||||
expect(body.data).toMatchObject({
|
||||
journal_entry_id: 'je-1',
|
||||
voucher_number: 42,
|
||||
voucher_series: 'A',
|
||||
trip_count: 3,
|
||||
total_amount: 297.5,
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,79 @@
|
||||
import { NextResponse } from 'next/server'
|
||||
import { ensureInitialized } from '@/lib/init'
|
||||
import { withRouteContext } from '@/lib/api/with-route-context'
|
||||
import { validateBody } from '@/lib/api/validate'
|
||||
import { BookMileagePeriodSchema } from '@/lib/api/schemas'
|
||||
import { bookMileagePeriod } from '@/lib/mileage/mileage-service'
|
||||
|
||||
ensureInitialized()
|
||||
|
||||
export const POST = withRouteContext(
|
||||
'mileage.book',
|
||||
async (request, { supabase, companyId, user, log }) => {
|
||||
const validation = await validateBody(request, BookMileagePeriodSchema)
|
||||
if (!validation.success) return validation.response
|
||||
const body = validation.data
|
||||
|
||||
const result = await bookMileagePeriod(supabase, companyId, user.id, {
|
||||
from: body.from,
|
||||
to: body.to,
|
||||
entryDate: body.entry_date,
|
||||
counterAccount: body.counter_account,
|
||||
employeeId: body.employee_id,
|
||||
})
|
||||
|
||||
if (!result.ok) {
|
||||
if (result.code === 'NO_TRIPS') {
|
||||
return NextResponse.json(
|
||||
{ error: 'Inga obokförda resor i den valda perioden' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
if (result.code === 'MIXED_EMPLOYEES') {
|
||||
return NextResponse.json(
|
||||
{ error: 'Resorna i perioden gäller flera anställda. Bokför per anställd.' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
if (result.code === 'PERIOD_NOT_OPEN') {
|
||||
return NextResponse.json(
|
||||
{ error: 'Bokföringsdatumet ligger i en stängd eller låst period' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
if (result.code === 'CLAIM_LOST' || result.code === 'TRIPS_CHANGED') {
|
||||
return NextResponse.json(
|
||||
{ error: 'Körjournalen ändrades samtidigt av en annan bokning. Ladda om och försök igen.' },
|
||||
{ status: 409 }
|
||||
)
|
||||
}
|
||||
// STAMP_FAILED: the verifikat exists but some trips could not be marked
|
||||
// as booked. Surface loudly so the user does not book the period twice.
|
||||
log.error('mileage stamp failed after verifikat creation', undefined, {
|
||||
operation: 'mileage.book',
|
||||
companyId,
|
||||
entityType: 'journal_entry',
|
||||
entityId: result.journalEntryId,
|
||||
})
|
||||
return NextResponse.json(
|
||||
{
|
||||
error:
|
||||
'Verifikatet skapades men alla resor kunde inte markeras som bokförda. Kontrollera körjournalen innan du bokför perioden igen.',
|
||||
},
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
data: {
|
||||
journal_entry_id: result.journalEntryId,
|
||||
voucher_series: result.voucherSeries,
|
||||
voucher_number: result.voucherNumber,
|
||||
trip_count: result.tripCount,
|
||||
total_amount: result.totalAmount,
|
||||
summaries: result.summaries,
|
||||
},
|
||||
})
|
||||
},
|
||||
{ requireWrite: true }
|
||||
)
|
||||
@@ -0,0 +1,63 @@
|
||||
import { NextResponse } from 'next/server'
|
||||
import { ensureInitialized } from '@/lib/init'
|
||||
import { withRouteContext } from '@/lib/api/with-route-context'
|
||||
import { ISO_DATE_RE } from '@/lib/invariants'
|
||||
import { listTrips } from '@/lib/mileage/mileage-service'
|
||||
import { mileageTripsToCsv } from '@/lib/mileage/csv-export'
|
||||
|
||||
ensureInitialized()
|
||||
|
||||
/** Körjournal CSV export (Skatteverket audit underlag). */
|
||||
export const GET = withRouteContext('mileage.export', async (request, { supabase, companyId }) => {
|
||||
const { searchParams } = new URL(request.url)
|
||||
const from = searchParams.get('from') || undefined
|
||||
const to = searchParams.get('to') || undefined
|
||||
// Validated dates are also what the Content-Disposition filename is built
|
||||
// from, so nothing user-controlled reaches the header unchecked.
|
||||
for (const value of [from, to]) {
|
||||
if (value !== undefined && !ISO_DATE_RE.test(value)) {
|
||||
return NextResponse.json({ error: 'Ogiltigt datum (ÅÅÅÅ-MM-DD)' }, { status: 400 })
|
||||
}
|
||||
}
|
||||
|
||||
const trips = await listTrips(supabase, companyId, { from, to })
|
||||
// Oldest first in the export: a körjournal reads chronologically.
|
||||
trips.reverse()
|
||||
|
||||
const entryIds = [...new Set(trips.map((t) => t.journal_entry_id).filter(Boolean))] as string[]
|
||||
const voucherLabels = new Map<string, string>()
|
||||
if (entryIds.length > 0) {
|
||||
const { data: entries } = await supabase
|
||||
.from('journal_entries')
|
||||
.select('id, voucher_series, voucher_number')
|
||||
.eq('company_id', companyId)
|
||||
.in('id', entryIds)
|
||||
for (const entry of entries || []) {
|
||||
voucherLabels.set(entry.id, `${entry.voucher_series}${entry.voucher_number}`)
|
||||
}
|
||||
}
|
||||
|
||||
// Driver attribution (BFL 5 kap 6-7 §): name the employee per trip so a
|
||||
// multi-employee körjournal stays attributable in the exported underlag.
|
||||
const employeeIds = [...new Set(trips.map((t) => t.employee_id).filter(Boolean))] as string[]
|
||||
const driverLabels = new Map<string, string>()
|
||||
if (employeeIds.length > 0) {
|
||||
const { data: employees } = await supabase
|
||||
.from('employees')
|
||||
.select('id, first_name, last_name')
|
||||
.eq('company_id', companyId)
|
||||
.in('id', employeeIds)
|
||||
for (const employee of employees || []) {
|
||||
driverLabels.set(employee.id, `${employee.first_name} ${employee.last_name}`)
|
||||
}
|
||||
}
|
||||
|
||||
const csv = mileageTripsToCsv(trips, voucherLabels, driverLabels)
|
||||
const suffix = [from, to].filter(Boolean).join('_')
|
||||
return new NextResponse(csv, {
|
||||
headers: {
|
||||
'Content-Type': 'text/csv; charset=utf-8',
|
||||
'Content-Disposition': `attachment; filename="korjournal${suffix ? `_${suffix}` : ''}.csv"`,
|
||||
},
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,61 @@
|
||||
import { NextResponse } from 'next/server'
|
||||
import { ensureInitialized } from '@/lib/init'
|
||||
import { withRouteContext } from '@/lib/api/with-route-context'
|
||||
import { validateBody } from '@/lib/api/validate'
|
||||
import { MileageSalaryPushSchema } from '@/lib/api/schemas'
|
||||
import { pushMileageToSalaryRun } from '@/lib/mileage/mileage-service'
|
||||
|
||||
ensureInitialized()
|
||||
|
||||
export const POST = withRouteContext(
|
||||
'mileage.salary_push',
|
||||
async (request, { supabase, companyId }) => {
|
||||
const validation = await validateBody(request, MileageSalaryPushSchema)
|
||||
if (!validation.success) return validation.response
|
||||
const body = validation.data
|
||||
|
||||
const result = await pushMileageToSalaryRun(supabase, companyId, {
|
||||
runId: body.run_id,
|
||||
employeeId: body.employee_id,
|
||||
from: body.from,
|
||||
to: body.to,
|
||||
includeUnassigned: body.include_unassigned,
|
||||
})
|
||||
|
||||
if (!result.ok) {
|
||||
switch (result.code) {
|
||||
case 'RUN_NOT_FOUND':
|
||||
return NextResponse.json({ error: 'Lönekörningen hittades inte' }, { status: 404 })
|
||||
case 'EMPLOYEE_NOT_IN_RUN':
|
||||
return NextResponse.json(
|
||||
{ error: 'Den anställda ingår inte i lönekörningen' },
|
||||
{ status: 404 }
|
||||
)
|
||||
case 'RUN_NOT_EDITABLE':
|
||||
return NextResponse.json(
|
||||
{ error: 'Lönekörningen kan inte längre ändras' },
|
||||
{ status: 409 }
|
||||
)
|
||||
case 'NO_TRIPS':
|
||||
return NextResponse.json(
|
||||
{ error: 'Inga obokförda resor i den valda perioden' },
|
||||
{ status: 400 }
|
||||
)
|
||||
case 'CLAIM_LOST':
|
||||
return NextResponse.json(
|
||||
{ error: 'Körjournalen ändrades samtidigt av en annan bokning. Ladda om och försök igen.' },
|
||||
{ status: 409 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
data: {
|
||||
trip_count: result.tripCount,
|
||||
total_amount: result.totalAmount,
|
||||
summaries: result.summaries,
|
||||
},
|
||||
})
|
||||
},
|
||||
{ requireWrite: true }
|
||||
)
|
||||
@@ -0,0 +1,160 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { NextResponse } from 'next/server'
|
||||
|
||||
vi.mock('@/lib/init', () => ({ ensureInitialized: vi.fn() }))
|
||||
vi.mock('@/lib/auth/require-auth', () => ({ requireAuth: vi.fn() }))
|
||||
vi.mock('@/lib/company/context', () => ({
|
||||
getActiveCompanyId: vi.fn().mockResolvedValue('company-1'),
|
||||
requireCompanyId: vi.fn().mockResolvedValue('company-1'),
|
||||
}))
|
||||
vi.mock('@/lib/auth/require-write', () => ({
|
||||
requireWritePermission: vi.fn().mockResolvedValue({ ok: true }),
|
||||
}))
|
||||
|
||||
import { PATCH, DELETE } from '../route'
|
||||
import { requireAuth } from '@/lib/auth/require-auth'
|
||||
|
||||
const params = { params: Promise.resolve({ id: 'trip-1' }) } as never
|
||||
|
||||
type ExistingRow = {
|
||||
id: string
|
||||
status: string
|
||||
vehicle_type: string
|
||||
vehicle_registration: string | null
|
||||
} | null
|
||||
|
||||
function supabaseWith(existing: ExistingRow, updated: unknown = { id: 'trip-1' }) {
|
||||
const chain: Record<string, unknown> = {}
|
||||
for (const method of ['select', 'eq', 'update', 'delete', 'in']) {
|
||||
chain[method] = vi.fn(() => chain)
|
||||
}
|
||||
chain.maybeSingle = vi.fn(() => Promise.resolve({ data: existing, error: null }))
|
||||
chain.single = vi.fn(() => Promise.resolve({ data: updated, error: null }))
|
||||
chain.then = (resolve: (v: unknown) => unknown) =>
|
||||
Promise.resolve({ data: null, error: null }).then(resolve)
|
||||
return { from: vi.fn(() => chain), chain }
|
||||
}
|
||||
|
||||
function authed(supabase: unknown) {
|
||||
vi.mocked(requireAuth).mockResolvedValue({
|
||||
user: { id: 'user-1' } as never,
|
||||
supabase: supabase as never,
|
||||
error: null,
|
||||
} as never)
|
||||
}
|
||||
|
||||
function patchReq(body: unknown) {
|
||||
return new Request('https://x.test/api/mileage/trips/trip-1', {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
})
|
||||
}
|
||||
|
||||
const DRAFT_OWN_CAR: ExistingRow = {
|
||||
id: 'trip-1',
|
||||
status: 'draft',
|
||||
vehicle_type: 'own_car',
|
||||
vehicle_registration: null,
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
describe('PATCH /api/mileage/trips/[id]', () => {
|
||||
it('returns 401 when unauthenticated', async () => {
|
||||
vi.mocked(requireAuth).mockResolvedValue({
|
||||
user: null,
|
||||
supabase: null,
|
||||
error: NextResponse.json({ error: 'Unauthorized' }, { status: 401 }),
|
||||
} as never)
|
||||
const res = await PATCH(patchReq({ purpose: 'Nytt ärende' }), params)
|
||||
expect(res.status).toBe(401)
|
||||
})
|
||||
|
||||
it('returns 404 for a trip outside the company', async () => {
|
||||
authed(supabaseWith(null))
|
||||
const res = await PATCH(patchReq({ purpose: 'Nytt ärende' }), params)
|
||||
expect(res.status).toBe(404)
|
||||
})
|
||||
|
||||
it('returns 409 for a booked trip (underlag is immutable)', async () => {
|
||||
authed(
|
||||
supabaseWith({ ...DRAFT_OWN_CAR, status: 'booked' })
|
||||
)
|
||||
const res = await PATCH(patchReq({ purpose: 'Nytt ärende' }), params)
|
||||
expect(res.status).toBe(409)
|
||||
})
|
||||
|
||||
it('rejects assigning an employee outside the company', async () => {
|
||||
// maybeSingle serves the trip lookup first, then the employee lookup: the
|
||||
// second call finds no company-scoped employee row.
|
||||
const supabase = supabaseWith(DRAFT_OWN_CAR)
|
||||
const maybeSingle = supabase.chain.maybeSingle as ReturnType<typeof vi.fn>
|
||||
maybeSingle
|
||||
.mockImplementationOnce(() => Promise.resolve({ data: DRAFT_OWN_CAR, error: null }))
|
||||
.mockImplementationOnce(() => Promise.resolve({ data: null, error: null }))
|
||||
authed(supabase)
|
||||
const res = await PATCH(
|
||||
patchReq({ employee_id: '9f8e7d6c-5b4a-4321-8abc-def012345678' }),
|
||||
params
|
||||
)
|
||||
expect(res.status).toBe(400)
|
||||
const body = await res.json()
|
||||
expect(body.error).toContain('anställda')
|
||||
})
|
||||
|
||||
it('rejects switching to förmånsbil when neither patch nor row has a regnr', async () => {
|
||||
authed(supabaseWith(DRAFT_OWN_CAR))
|
||||
const res = await PATCH(patchReq({ vehicle_type: 'company_car_fossil' }), params)
|
||||
expect(res.status).toBe(400)
|
||||
})
|
||||
|
||||
it('allows switching to förmånsbil when the stored row already has a regnr', async () => {
|
||||
authed(
|
||||
supabaseWith({ ...DRAFT_OWN_CAR, vehicle_registration: 'ABC123' })
|
||||
)
|
||||
const res = await PATCH(patchReq({ vehicle_type: 'company_car_fossil' }), params)
|
||||
expect(res.status).toBe(200)
|
||||
})
|
||||
|
||||
it('updates a draft trip', async () => {
|
||||
authed(supabaseWith(DRAFT_OWN_CAR, { id: 'trip-1', purpose: 'Nytt ärende' }))
|
||||
const res = await PATCH(patchReq({ purpose: 'Nytt ärende' }), params)
|
||||
expect(res.status).toBe(200)
|
||||
const body = await res.json()
|
||||
expect(body.data.purpose).toBe('Nytt ärende')
|
||||
})
|
||||
})
|
||||
|
||||
describe('DELETE /api/mileage/trips/[id]', () => {
|
||||
it('returns 404 for a trip outside the company', async () => {
|
||||
authed(supabaseWith(null))
|
||||
const res = await DELETE(
|
||||
new Request('https://x.test/api/mileage/trips/trip-1', { method: 'DELETE' }),
|
||||
params
|
||||
)
|
||||
expect(res.status).toBe(404)
|
||||
})
|
||||
|
||||
it('returns 409 for a booked trip (BFL retention)', async () => {
|
||||
authed(supabaseWith({ ...DRAFT_OWN_CAR, status: 'booked' }))
|
||||
const res = await DELETE(
|
||||
new Request('https://x.test/api/mileage/trips/trip-1', { method: 'DELETE' }),
|
||||
params
|
||||
)
|
||||
expect(res.status).toBe(409)
|
||||
})
|
||||
|
||||
it('deletes a draft trip', async () => {
|
||||
authed(supabaseWith(DRAFT_OWN_CAR))
|
||||
const res = await DELETE(
|
||||
new Request('https://x.test/api/mileage/trips/trip-1', { method: 'DELETE' }),
|
||||
params
|
||||
)
|
||||
expect(res.status).toBe(200)
|
||||
const body = await res.json()
|
||||
expect(body.data.deleted).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,122 @@
|
||||
import { NextResponse } from 'next/server'
|
||||
import { ensureInitialized } from '@/lib/init'
|
||||
import { withRouteContext } from '@/lib/api/with-route-context'
|
||||
import { validateBody } from '@/lib/api/validate'
|
||||
import { UpdateMileageTripSchema } from '@/lib/api/schemas'
|
||||
|
||||
ensureInitialized()
|
||||
|
||||
type Params = { params: Promise<{ id: string }> }
|
||||
|
||||
/**
|
||||
* A booked trip is körjournal underlag for its verifikat: immutable and
|
||||
* undeletable (DB trigger backstops the delete). Only drafts can change.
|
||||
*/
|
||||
export const PATCH = withRouteContext<Params>(
|
||||
'mileage.trips.update',
|
||||
async (request, { supabase, companyId }, { params }) => {
|
||||
const { id } = await params
|
||||
const validation = await validateBody(request, UpdateMileageTripSchema)
|
||||
if (!validation.success) return validation.response
|
||||
|
||||
const { data: existing } = await supabase
|
||||
.from('mileage_trips')
|
||||
.select('id, status, vehicle_type, vehicle_registration')
|
||||
.eq('company_id', companyId)
|
||||
.eq('id', id)
|
||||
.maybeSingle()
|
||||
|
||||
if (!existing) {
|
||||
return NextResponse.json({ error: 'Resan hittades inte' }, { status: 404 })
|
||||
}
|
||||
if (existing.status !== 'draft') {
|
||||
return NextResponse.json(
|
||||
{ error: 'Resan är bokförd och kan inte ändras. Makulera verifikatet först.' },
|
||||
{ status: 409 }
|
||||
)
|
||||
}
|
||||
|
||||
// The employees FK is not company-scoped: verify a newly assigned
|
||||
// employee belongs to this company (mirrors createTrip).
|
||||
if (validation.data.employee_id) {
|
||||
const { data: employee } = await supabase
|
||||
.from('employees')
|
||||
.select('id')
|
||||
.eq('company_id', companyId)
|
||||
.eq('id', validation.data.employee_id)
|
||||
.maybeSingle()
|
||||
if (!employee) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Den anställda hittades inte i företaget' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Enforce the förmånsbil regnr rule on the EFFECTIVE row (partial update
|
||||
// merged over the stored values), mirroring CreateMileageTripSchema.
|
||||
const effectiveVehicleType = validation.data.vehicle_type ?? existing.vehicle_type
|
||||
const effectiveRegistration =
|
||||
validation.data.vehicle_registration !== undefined
|
||||
? validation.data.vehicle_registration
|
||||
: existing.vehicle_registration
|
||||
if (effectiveVehicleType !== 'own_car' && !effectiveRegistration?.trim()) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Ange registreringsnummer för förmånsbilen' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
const { data: updated, error } = await supabase
|
||||
.from('mileage_trips')
|
||||
.update(validation.data)
|
||||
.eq('company_id', companyId)
|
||||
.eq('id', id)
|
||||
.eq('status', 'draft')
|
||||
.select()
|
||||
.single()
|
||||
|
||||
if (error || !updated) {
|
||||
return NextResponse.json({ error: 'Resan kunde inte uppdateras' }, { status: 500 })
|
||||
}
|
||||
return NextResponse.json({ data: updated })
|
||||
},
|
||||
{ requireWrite: true }
|
||||
)
|
||||
|
||||
export const DELETE = withRouteContext<Params>(
|
||||
'mileage.trips.delete',
|
||||
async (_request, { supabase, companyId }, { params }) => {
|
||||
const { id } = await params
|
||||
|
||||
const { data: existing } = await supabase
|
||||
.from('mileage_trips')
|
||||
.select('id, status')
|
||||
.eq('company_id', companyId)
|
||||
.eq('id', id)
|
||||
.maybeSingle()
|
||||
|
||||
if (!existing) {
|
||||
return NextResponse.json({ error: 'Resan hittades inte' }, { status: 404 })
|
||||
}
|
||||
if (existing.status !== 'draft') {
|
||||
return NextResponse.json(
|
||||
{ error: 'Resan är bokförd och kan inte tas bort (underlag bevaras enligt bokföringslagen).' },
|
||||
{ status: 409 }
|
||||
)
|
||||
}
|
||||
|
||||
const { error } = await supabase
|
||||
.from('mileage_trips')
|
||||
.delete()
|
||||
.eq('company_id', companyId)
|
||||
.eq('id', id)
|
||||
.eq('status', 'draft')
|
||||
|
||||
if (error) {
|
||||
return NextResponse.json({ error: 'Resan kunde inte tas bort' }, { status: 500 })
|
||||
}
|
||||
return NextResponse.json({ data: { deleted: true } })
|
||||
},
|
||||
{ requireWrite: true }
|
||||
)
|
||||
@@ -0,0 +1,131 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { NextResponse } from 'next/server'
|
||||
|
||||
vi.mock('@/lib/init', () => ({ ensureInitialized: vi.fn() }))
|
||||
vi.mock('@/lib/auth/require-auth', () => ({ requireAuth: vi.fn() }))
|
||||
vi.mock('@/lib/company/context', () => ({
|
||||
getActiveCompanyId: vi.fn().mockResolvedValue('company-1'),
|
||||
requireCompanyId: vi.fn().mockResolvedValue('company-1'),
|
||||
}))
|
||||
vi.mock('@/lib/auth/require-write', () => ({
|
||||
requireWritePermission: vi.fn().mockResolvedValue({ ok: true }),
|
||||
}))
|
||||
vi.mock('@/lib/mileage/mileage-service', () => ({
|
||||
listTrips: vi.fn(),
|
||||
createTrip: vi.fn(),
|
||||
}))
|
||||
|
||||
import { GET, POST } from '../route'
|
||||
import { requireAuth } from '@/lib/auth/require-auth'
|
||||
import { createTrip, listTrips } from '@/lib/mileage/mileage-service'
|
||||
|
||||
const params = { params: Promise.resolve({}) } as never
|
||||
|
||||
function authed() {
|
||||
vi.mocked(requireAuth).mockResolvedValue({
|
||||
user: { id: 'user-1' } as never,
|
||||
supabase: {} as never,
|
||||
error: null,
|
||||
} as never)
|
||||
}
|
||||
|
||||
function unauthed() {
|
||||
vi.mocked(requireAuth).mockResolvedValue({
|
||||
user: null,
|
||||
supabase: null,
|
||||
error: NextResponse.json({ error: 'Unauthorized' }, { status: 401 }),
|
||||
} as never)
|
||||
}
|
||||
|
||||
const VALID_TRIP = {
|
||||
trip_date: '2026-05-10',
|
||||
distance_km: 32,
|
||||
from_location: 'Kontoret',
|
||||
to_location: 'Kunden',
|
||||
purpose: 'Kundbesök',
|
||||
}
|
||||
|
||||
function postReq(body: unknown) {
|
||||
return new Request('https://x.test/api/mileage/trips', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
})
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
describe('GET /api/mileage/trips', () => {
|
||||
it('returns 401 when unauthenticated', async () => {
|
||||
unauthed()
|
||||
const res = await GET(new Request('https://x.test/api/mileage/trips'), params)
|
||||
expect(res.status).toBe(401)
|
||||
})
|
||||
|
||||
it('returns the trip list and forwards filters', async () => {
|
||||
authed()
|
||||
vi.mocked(listTrips).mockResolvedValue([{ id: 't1' }] as never)
|
||||
const res = await GET(
|
||||
new Request('https://x.test/api/mileage/trips?from=2026-05-01&to=2026-05-31&status=draft'),
|
||||
params
|
||||
)
|
||||
expect(res.status).toBe(200)
|
||||
const body = await res.json()
|
||||
expect(body.data).toHaveLength(1)
|
||||
expect(vi.mocked(listTrips).mock.calls[0][2]).toMatchObject({
|
||||
from: '2026-05-01',
|
||||
to: '2026-05-31',
|
||||
status: 'draft',
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('POST /api/mileage/trips', () => {
|
||||
it('returns 401 when unauthenticated', async () => {
|
||||
unauthed()
|
||||
const res = await POST(postReq(VALID_TRIP), params)
|
||||
expect(res.status).toBe(401)
|
||||
})
|
||||
|
||||
it('returns 400 on invalid body (missing purpose, negative km)', async () => {
|
||||
authed()
|
||||
const res = await POST(
|
||||
postReq({ trip_date: '2026-05-10', distance_km: -5, from_location: 'A', to_location: 'B' }),
|
||||
params
|
||||
)
|
||||
expect(res.status).toBe(400)
|
||||
expect(createTrip).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('rejects a förmånsbil trip without vehicle_registration', async () => {
|
||||
authed()
|
||||
const res = await POST(
|
||||
postReq({ ...VALID_TRIP, vehicle_type: 'company_car_fossil' }),
|
||||
params
|
||||
)
|
||||
expect(res.status).toBe(400)
|
||||
expect(createTrip).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('rejects odometer_end <= odometer_start', async () => {
|
||||
authed()
|
||||
const res = await POST(
|
||||
postReq({ ...VALID_TRIP, odometer_start: 1032, odometer_end: 1000 }),
|
||||
params
|
||||
)
|
||||
expect(res.status).toBe(400)
|
||||
})
|
||||
|
||||
it('creates the trip and returns 201', async () => {
|
||||
authed()
|
||||
vi.mocked(createTrip).mockResolvedValue({ id: 't1', status: 'draft' } as never)
|
||||
const res = await POST(postReq(VALID_TRIP), params)
|
||||
expect(res.status).toBe(201)
|
||||
const body = await res.json()
|
||||
expect(body.data.id).toBe('t1')
|
||||
expect(vi.mocked(createTrip).mock.calls[0][1]).toBe('company-1')
|
||||
expect(vi.mocked(createTrip).mock.calls[0][2]).toBe('user-1')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,34 @@
|
||||
import { NextResponse } from 'next/server'
|
||||
import { ensureInitialized } from '@/lib/init'
|
||||
import { withRouteContext } from '@/lib/api/with-route-context'
|
||||
import { validateBody } from '@/lib/api/validate'
|
||||
import { CreateMileageTripSchema } from '@/lib/api/schemas'
|
||||
import { createTrip, listTrips } from '@/lib/mileage/mileage-service'
|
||||
|
||||
ensureInitialized()
|
||||
|
||||
export const GET = withRouteContext('mileage.trips.list', async (request, { supabase, companyId }) => {
|
||||
const { searchParams } = new URL(request.url)
|
||||
const status = searchParams.get('status')
|
||||
|
||||
const trips = await listTrips(supabase, companyId, {
|
||||
from: searchParams.get('from') || undefined,
|
||||
to: searchParams.get('to') || undefined,
|
||||
status: status === 'draft' || status === 'booked' ? status : undefined,
|
||||
employeeId: searchParams.get('employee_id') || undefined,
|
||||
})
|
||||
|
||||
return NextResponse.json({ data: trips })
|
||||
})
|
||||
|
||||
export const POST = withRouteContext(
|
||||
'mileage.trips.create',
|
||||
async (request, { supabase, companyId, user }) => {
|
||||
const validation = await validateBody(request, CreateMileageTripSchema)
|
||||
if (!validation.success) return validation.response
|
||||
|
||||
const trip = await createTrip(supabase, companyId, user.id, validation.data)
|
||||
return NextResponse.json({ data: trip }, { status: 201 })
|
||||
},
|
||||
{ requireWrite: true }
|
||||
)
|
||||
@@ -45,6 +45,7 @@ import {
|
||||
PanelLeftClose,
|
||||
Library,
|
||||
BookCheck,
|
||||
Car,
|
||||
} from 'lucide-react'
|
||||
import { getBranding } from '@/lib/branding/service'
|
||||
import { ENABLED_EXTENSION_IDS as _ENABLED_EXTENSION_IDS } from '@/lib/extensions/_generated/enabled-extensions'
|
||||
@@ -114,6 +115,7 @@ type NavLabelKey =
|
||||
| 'reports'
|
||||
| 'import'
|
||||
| 'salary'
|
||||
| 'mileage'
|
||||
| 'employees'
|
||||
| 'vat_declaration'
|
||||
| 'skattekonto'
|
||||
@@ -192,6 +194,7 @@ const navItems: NavItem[] = [
|
||||
{ href: '/invoices', labelKey: 'invoices', icon: ReceiptText, group: 'arbeta' },
|
||||
{ href: '/supplier-invoices', labelKey: 'supplier_invoices', icon: Wallet, group: 'arbeta' },
|
||||
{ href: '/salary', labelKey: 'salary', icon: HandCoins, group: 'arbeta', employerOnly: true },
|
||||
{ href: '/mileage', labelKey: 'mileage', icon: Car, group: 'arbeta' },
|
||||
// Analys: read the numbers.
|
||||
{ href: '/kpi', labelKey: 'kpi', icon: TrendingUp, group: 'analys' },
|
||||
{ href: '/reports', labelKey: 'reports', icon: BarChart3, group: 'analys' },
|
||||
|
||||
@@ -10493,6 +10493,246 @@ export const tools: McpTool[] = [
|
||||
},
|
||||
},
|
||||
// ── Payroll (Lönehantering) ──────────────────────────────────
|
||||
{
|
||||
name: 'gnubok_list_mileage_trips',
|
||||
title: 'List Mileage Trips (Körjournal)',
|
||||
catalogVisibility: 'search',
|
||||
description: 'List körjournal trips for the active company. Filter by date range, status (draft = not yet booked, booked) or employee. Use before gnubok_book_mileage_period to see what would be booked.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
additionalProperties: false,
|
||||
properties: {
|
||||
from: { type: 'string', description: 'From date (YYYY-MM-DD)' },
|
||||
to: { type: 'string', description: 'To date (YYYY-MM-DD)' },
|
||||
status: { type: 'string', enum: ['draft', 'booked'], description: 'Filter by status' },
|
||||
employee_id: { type: 'string', description: 'Filter by employee UUID' },
|
||||
},
|
||||
},
|
||||
outputSchema: {
|
||||
type: 'object',
|
||||
additionalProperties: false,
|
||||
properties: {
|
||||
trips: { type: 'array', items: { type: 'object' } },
|
||||
count: { type: 'number' },
|
||||
total_km: { type: 'number' },
|
||||
draft_km: { type: 'number' },
|
||||
},
|
||||
required: ['trips', 'count', 'total_km', 'draft_km'],
|
||||
},
|
||||
annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
|
||||
async execute(args, companyId, _userId, supabase) {
|
||||
const { listTrips } = await import('@/lib/mileage/mileage-service')
|
||||
const status = args.status as 'draft' | 'booked' | undefined
|
||||
const rows = await listTrips(supabase, companyId, {
|
||||
from: (args.from as string) || undefined,
|
||||
to: (args.to as string) || undefined,
|
||||
status: status === 'draft' || status === 'booked' ? status : undefined,
|
||||
employeeId: (args.employee_id as string) || undefined,
|
||||
})
|
||||
const { roundOre: round2 } = await import('@/lib/money')
|
||||
const trips = rows.map((t) => ({
|
||||
mileage_trip_id: t.id,
|
||||
trip_date: t.trip_date,
|
||||
vehicle_type: t.vehicle_type,
|
||||
vehicle_registration: t.vehicle_registration,
|
||||
odometer_start: t.odometer_start,
|
||||
odometer_end: t.odometer_end,
|
||||
distance_km: Number(t.distance_km),
|
||||
from_location: t.from_location,
|
||||
to_location: t.to_location,
|
||||
purpose: t.purpose,
|
||||
visited: t.visited,
|
||||
is_round_trip: t.is_round_trip,
|
||||
status: t.status,
|
||||
journal_entry_id: t.journal_entry_id,
|
||||
salary_run_id: t.salary_run_id,
|
||||
}))
|
||||
return {
|
||||
trips,
|
||||
count: trips.length,
|
||||
total_km: round2(trips.reduce((sum, t) => sum + t.distance_km, 0)),
|
||||
draft_km: round2(
|
||||
trips.filter((t) => t.status === 'draft').reduce((sum, t) => sum + t.distance_km, 0)
|
||||
),
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'gnubok_log_mileage_trip',
|
||||
title: 'Log Mileage Trip (Körjournal)',
|
||||
catalogVisibility: 'search',
|
||||
description: 'Stage a körjournal trip (date, route, km, purpose per Skatteverket requirements). Approve via gnubok_approve_pending_operation. The trip stays a draft until booked via gnubok_book_mileage_period.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
additionalProperties: false,
|
||||
properties: {
|
||||
trip_date: { type: 'string', description: 'Trip date (YYYY-MM-DD)' },
|
||||
vehicle_type: { type: 'string', enum: ['own_car', 'company_car_fossil', 'company_car_electric'], description: 'Vehicle type; drives the tax-free rate (default own_car, 25 kr/mil)' },
|
||||
vehicle_registration: { type: 'string', description: 'Registration number (regnr)' },
|
||||
odometer_start: { type: 'number', description: 'Odometer at start (km)' },
|
||||
odometer_end: { type: 'number', description: 'Odometer at arrival (km)' },
|
||||
distance_km: { type: 'number', description: 'Distance in km' },
|
||||
from_location: { type: 'string', description: 'Start location' },
|
||||
to_location: { type: 'string', description: 'Destination' },
|
||||
purpose: { type: 'string', description: 'Business purpose (ärende)' },
|
||||
visited: { type: 'string', description: 'Who/which company was visited' },
|
||||
is_round_trip: { type: 'boolean', description: 'Distance covers the return leg too' },
|
||||
employee_id: { type: 'string', description: 'Employee UUID when the trip belongs to an employee' },
|
||||
notes: { type: 'string', description: 'Free-text note' },
|
||||
},
|
||||
required: ['trip_date', 'distance_km', 'from_location', 'to_location', 'purpose'],
|
||||
},
|
||||
outputSchema: STAGED_OPERATION_SCHEMA,
|
||||
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
|
||||
async execute(args, companyId, userId, supabase, actor) {
|
||||
const tripDate = args.trip_date as string
|
||||
const distanceKm = args.distance_km as number
|
||||
if (typeof tripDate !== 'string' || !ISO_DATE_RE.test(tripDate)) {
|
||||
throw new Error('trip_date must be YYYY-MM-DD')
|
||||
}
|
||||
if (typeof distanceKm !== 'number' || !(distanceKm > 0)) {
|
||||
throw new Error('distance_km must be a positive number')
|
||||
}
|
||||
const odoStart = args.odometer_start as number | undefined
|
||||
const odoEnd = args.odometer_end as number | undefined
|
||||
if (odoStart != null && odoEnd != null && odoEnd <= odoStart) {
|
||||
throw new Error('odometer_end must be greater than odometer_start')
|
||||
}
|
||||
|
||||
const vehicleType = (args.vehicle_type as string) || 'own_car'
|
||||
if (vehicleType !== 'own_car' && !(args.vehicle_registration as string | undefined)?.trim()) {
|
||||
throw new Error('vehicle_registration is required for a förmånsbil trip (körjournal must identify the vehicle)')
|
||||
}
|
||||
// Preview the tax-free allowance at the schablon rate; non-fatal if the
|
||||
// payroll config year is missing.
|
||||
let approxAmount: number | undefined
|
||||
try {
|
||||
const { loadPayrollConfig } = await import('@/lib/salary/payroll-config')
|
||||
const { ratePerMil } = await import('@/lib/mileage/mileage-service')
|
||||
const { roundOre } = await import('@/lib/money')
|
||||
const config = await loadPayrollConfig(supabase, Number(tripDate.slice(0, 4)))
|
||||
approxAmount = roundOre((distanceKm / 10) * ratePerMil(config, vehicleType as never))
|
||||
} catch {
|
||||
approxAmount = undefined
|
||||
}
|
||||
|
||||
return stagePendingOperation(
|
||||
supabase, companyId, userId, 'log_mileage_trip',
|
||||
`Körjournal: ${args.from_location} till ${args.to_location} ${tripDate} (${distanceKm} km)`,
|
||||
{
|
||||
trip_date: tripDate,
|
||||
vehicle_type: vehicleType,
|
||||
vehicle_registration: args.vehicle_registration ?? null,
|
||||
odometer_start: odoStart ?? null,
|
||||
odometer_end: odoEnd ?? null,
|
||||
distance_km: distanceKm,
|
||||
from_location: args.from_location,
|
||||
to_location: args.to_location,
|
||||
purpose: args.purpose,
|
||||
visited: args.visited ?? null,
|
||||
is_round_trip: args.is_round_trip === true,
|
||||
employee_id: args.employee_id ?? null,
|
||||
notes: args.notes ?? null,
|
||||
},
|
||||
{
|
||||
trip_date: tripDate,
|
||||
route: `${args.from_location} → ${args.to_location}`,
|
||||
distance_km: distanceKm,
|
||||
purpose: args.purpose,
|
||||
vehicle_type: vehicleType,
|
||||
...(approxAmount != null ? { tax_free_allowance_sek: approxAmount } : {}),
|
||||
},
|
||||
actor,
|
||||
{
|
||||
description: 'Once approved, the trip is a draft in the körjournal. Book the period via gnubok_book_mileage_period.',
|
||||
tool: 'gnubok_book_mileage_period',
|
||||
},
|
||||
)
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'gnubok_book_mileage_period',
|
||||
title: 'Book Mileage Period (Milersättning)',
|
||||
catalogVisibility: 'search',
|
||||
description: 'Stage booking of all draft körjournal trips in a date range as one milersättning verifikat: debit 7331 at the tax-free schablon rate, credit 2820/2893/1930. Approve via gnubok_approve_pending_operation. Call gnubok_list_mileage_trips first.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
additionalProperties: false,
|
||||
properties: {
|
||||
from: { type: 'string', description: 'Period start (YYYY-MM-DD)' },
|
||||
to: { type: 'string', description: 'Period end (YYYY-MM-DD)' },
|
||||
entry_date: { type: 'string', description: 'Verifikat date (YYYY-MM-DD); must be in an open period' },
|
||||
counter_account: { type: 'string', enum: ['2820', '2893', '1930'], description: 'Credit side: 2820 skuld till anställda (default), 2893 avräkning aktieägare, 1930 when already paid out from bank' },
|
||||
employee_id: { type: 'string', description: 'Only book trips for this employee UUID' },
|
||||
},
|
||||
required: ['from', 'to', 'entry_date'],
|
||||
},
|
||||
outputSchema: STAGED_OPERATION_SCHEMA,
|
||||
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
|
||||
async execute(args, companyId, userId, supabase, actor) {
|
||||
const from = args.from as string
|
||||
const to = args.to as string
|
||||
const entryDate = args.entry_date as string
|
||||
for (const [label, value] of [['from', from], ['to', to], ['entry_date', entryDate]] as const) {
|
||||
if (typeof value !== 'string' || !ISO_DATE_RE.test(value)) {
|
||||
throw new Error(`${label} must be YYYY-MM-DD`)
|
||||
}
|
||||
}
|
||||
if (from > to) throw new Error('from must be <= to')
|
||||
if (from.slice(0, 4) !== to.slice(0, 4)) {
|
||||
throw new Error('Schablon rates are per calendar year: book one year at a time')
|
||||
}
|
||||
const counterAccount = (args.counter_account as string) || '2820'
|
||||
|
||||
// Read-only preflight: aggregate the draft trips so the approver sees
|
||||
// exactly what would be booked. The commit path re-reads atomically.
|
||||
const { listTrips, summarizeTrips } = await import('@/lib/mileage/mileage-service')
|
||||
const { loadPayrollConfig } = await import('@/lib/salary/payroll-config')
|
||||
const { roundOre } = await import('@/lib/money')
|
||||
const trips = await listTrips(supabase, companyId, {
|
||||
from, to, status: 'draft',
|
||||
employeeId: (args.employee_id as string) || undefined,
|
||||
})
|
||||
if (trips.length === 0) {
|
||||
throw new Error('No unbooked trips in the selected period. Log trips first via gnubok_log_mileage_trip.')
|
||||
}
|
||||
if (new Set(trips.map((t) => t.employee_id ?? 'unassigned')).size > 1) {
|
||||
throw new Error('The period spans several employees. Book per employee by passing employee_id (BFL motpart traceability).')
|
||||
}
|
||||
const config = await loadPayrollConfig(supabase, Number(to.slice(0, 4)))
|
||||
const summaries = summarizeTrips(trips, config)
|
||||
const totalAmount = roundOre(summaries.reduce((sum, s) => sum + s.amount, 0))
|
||||
|
||||
return stagePendingOperation(
|
||||
supabase, companyId, userId, 'book_mileage_period',
|
||||
`Bokför milersättning ${from} till ${to}: ${totalAmount} kr (${trips.length} resor)`,
|
||||
{
|
||||
from, to,
|
||||
entry_date: entryDate,
|
||||
counter_account: counterAccount,
|
||||
// Freeze the previewed trip set: the commit fails if the drafts in
|
||||
// range change between staging and approval.
|
||||
trip_ids: trips.map((t) => t.id),
|
||||
...(args.employee_id ? { employee_id: args.employee_id } : {}),
|
||||
},
|
||||
{
|
||||
trip_count: trips.length,
|
||||
total_amount: totalAmount,
|
||||
debit_account: '7331',
|
||||
credit_account: counterAccount,
|
||||
summaries: summaries.map((s) => ({
|
||||
vehicle_type: s.vehicle_type,
|
||||
total_mil: s.total_mil,
|
||||
rate_per_mil: s.rate_per_mil,
|
||||
amount: s.amount,
|
||||
})),
|
||||
},
|
||||
actor,
|
||||
undefined,
|
||||
{ dateForPeriodCheck: entryDate },
|
||||
)
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'gnubok_list_employees',
|
||||
title: 'List Employees',
|
||||
|
||||
@@ -3164,3 +3164,80 @@ export const DimensionTaggingApplySchema = z.object({
|
||||
dimensions: DimensionsBagSchema,
|
||||
reason: z.string().trim().min(3).max(500),
|
||||
})
|
||||
|
||||
// ============================================================
|
||||
// Körjournal (mileage trips)
|
||||
// ============================================================
|
||||
|
||||
const mileageVehicleType = z.enum(['own_car', 'company_car_fossil', 'company_car_electric'])
|
||||
|
||||
export const CreateMileageTripSchema = z
|
||||
.object({
|
||||
trip_date: saneIsoDate,
|
||||
vehicle_type: mileageVehicleType.default('own_car'),
|
||||
vehicle_registration: z.string().trim().max(20).optional().nullable(),
|
||||
odometer_start: z.number().int().nonnegative().optional().nullable(),
|
||||
odometer_end: z.number().int().nonnegative().optional().nullable(),
|
||||
distance_km: z.number().positive().max(100000),
|
||||
from_location: z.string().trim().min(1).max(200),
|
||||
to_location: z.string().trim().min(1).max(200),
|
||||
purpose: z.string().trim().min(1).max(500),
|
||||
visited: z.string().trim().max(200).optional().nullable(),
|
||||
is_round_trip: z.boolean().default(false),
|
||||
employee_id: uuid.optional().nullable(),
|
||||
notes: z.string().trim().max(1000).optional().nullable(),
|
||||
})
|
||||
.refine(
|
||||
(t) =>
|
||||
t.odometer_start == null || t.odometer_end == null || t.odometer_end > t.odometer_start,
|
||||
{ message: 'Mätarställning vid ankomst måste vara högre än vid start' }
|
||||
)
|
||||
.refine((t) => t.vehicle_type === 'own_car' || Boolean(t.vehicle_registration?.trim()), {
|
||||
message: 'Ange registreringsnummer för förmånsbilen',
|
||||
})
|
||||
|
||||
export const UpdateMileageTripSchema = z
|
||||
.object({
|
||||
trip_date: saneIsoDate.optional(),
|
||||
vehicle_type: mileageVehicleType.optional(),
|
||||
vehicle_registration: z.string().trim().max(20).optional().nullable(),
|
||||
odometer_start: z.number().int().nonnegative().optional().nullable(),
|
||||
odometer_end: z.number().int().nonnegative().optional().nullable(),
|
||||
distance_km: z.number().positive().max(100000).optional(),
|
||||
from_location: z.string().trim().min(1).max(200).optional(),
|
||||
to_location: z.string().trim().min(1).max(200).optional(),
|
||||
purpose: z.string().trim().min(1).max(500).optional(),
|
||||
visited: z.string().trim().max(200).optional().nullable(),
|
||||
is_round_trip: z.boolean().optional(),
|
||||
employee_id: uuid.optional().nullable(),
|
||||
notes: z.string().trim().max(1000).optional().nullable(),
|
||||
})
|
||||
.refine((t) => Object.keys(t).length > 0, { message: 'Inga fält att uppdatera' })
|
||||
|
||||
export const BookMileagePeriodSchema = z
|
||||
.object({
|
||||
from: saneIsoDate,
|
||||
to: saneIsoDate,
|
||||
entry_date: saneIsoDate,
|
||||
counter_account: z.enum(['2820', '2893', '1930']).default('2820'),
|
||||
employee_id: uuid.optional(),
|
||||
})
|
||||
.refine((p) => p.from <= p.to, { message: 'Ogiltigt datumintervall' })
|
||||
// Schablon rates are per calendar year: a cross-year period would book
|
||||
// every trip at one year's rate.
|
||||
.refine((p) => p.from.slice(0, 4) === p.to.slice(0, 4), {
|
||||
message: 'Milersättning bokförs per kalenderår: dela upp perioden per år',
|
||||
})
|
||||
|
||||
export const MileageSalaryPushSchema = z
|
||||
.object({
|
||||
run_id: uuid,
|
||||
employee_id: uuid,
|
||||
from: saneIsoDate,
|
||||
to: saneIsoDate,
|
||||
include_unassigned: z.boolean().default(true),
|
||||
})
|
||||
.refine((p) => p.from <= p.to, { message: 'Ogiltigt datumintervall' })
|
||||
.refine((p) => p.from.slice(0, 4) === p.to.slice(0, 4), {
|
||||
message: 'Milersättning bokförs per kalenderår: dela upp perioden per år',
|
||||
})
|
||||
|
||||
@@ -19,8 +19,8 @@ export const API_KEY_SCOPES = {
|
||||
'suppliers:write': { label: 'Leverantörer: skriv', description: 'Skapa leverantörer; godkänn, kreditera, betal-länka och hantera leverantörsfakturor (6 verktyg)' },
|
||||
'reports:read': { label: 'Rapporter: läs', description: 'Kontoplan, huvudbok, balansräkning, resultaträkning, moms, KPI, reskontra, perioder, bankavstämning, SIE-export (12 verktyg)' },
|
||||
'bookkeeping:write': { label: 'Bokföring: skriv', description: 'Stänga/låsa perioder, ingående balans, bokslut, SIE-import, voucher-gap-förklaringar, kontoplan (skapa/ändra konton), verifikat-anteckningar' },
|
||||
'payroll:read': { label: 'Löner: läs', description: 'Lista anställda, lönekörningar, lönejournal (3 verktyg)' },
|
||||
'payroll:write': { label: 'Löner: skriv', description: 'Skapa lönekörning, beräkna, generera AGI (3 verktyg)' },
|
||||
'payroll:read': { label: 'Löner: läs', description: 'Lista anställda, lönekörningar, lönejournal, körjournal' },
|
||||
'payroll:write': { label: 'Löner: skriv', description: 'Skapa lönekörning, beräkna, generera AGI, logga körjournalresor' },
|
||||
// v1 REST API: added Phase 1
|
||||
'companies:read': { label: 'Företag: läs', description: 'Lista och visa företagsprofiler som API-nyckeln har tillgång till' },
|
||||
'companies:write': { label: 'Företag: skriv', description: 'Uppdatera företagsinställningar via stagade verktyg eller REST-endpointen PATCH /api/v1/companies/{companyId}/settings' },
|
||||
@@ -241,6 +241,11 @@ export const TOOL_SCOPE_MAP: Record<string, ApiKeyScope> = {
|
||||
gnubok_get_document_content: 'transactions:read',
|
||||
gnubok_attach_document_to_transaction: 'transactions:write',
|
||||
gnubok_link_document_to_voucher: 'bookkeeping:write',
|
||||
// Körjournal (mileage): trip log reads/writes are payroll surface
|
||||
// (milersättning, 7331); booking the verifikat is a journal write.
|
||||
gnubok_list_mileage_trips: 'payroll:read',
|
||||
gnubok_log_mileage_trip: 'payroll:write',
|
||||
gnubok_book_mileage_period: 'bookkeeping:write',
|
||||
// Payroll
|
||||
gnubok_list_employees: 'payroll:read',
|
||||
gnubok_get_salary_run: 'payroll:read',
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { mileageTripsToCsv } from '@/lib/mileage/csv-export'
|
||||
import type { MileageTrip } from '@/types'
|
||||
|
||||
function trip(overrides: Partial<MileageTrip>): MileageTrip {
|
||||
return {
|
||||
id: 'trip-1',
|
||||
company_id: 'company-1',
|
||||
user_id: 'user-1',
|
||||
employee_id: null,
|
||||
trip_date: '2026-05-10',
|
||||
vehicle_type: 'own_car',
|
||||
vehicle_registration: 'ABC123',
|
||||
odometer_start: 1000,
|
||||
odometer_end: 1032,
|
||||
distance_km: 32.3,
|
||||
from_location: 'Kontoret',
|
||||
to_location: 'Kunden',
|
||||
purpose: 'Kundbesök',
|
||||
visited: null,
|
||||
is_round_trip: false,
|
||||
status: 'draft',
|
||||
journal_entry_id: null,
|
||||
salary_run_id: null,
|
||||
notes: null,
|
||||
created_via: 'manual',
|
||||
created_at: '2026-05-10T00:00:00Z',
|
||||
updated_at: '2026-05-10T00:00:00Z',
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
describe('mileageTripsToCsv', () => {
|
||||
it('starts with a UTF-8 BOM and the Swedish header row', () => {
|
||||
const csv = mileageTripsToCsv([])
|
||||
expect(csv.charCodeAt(0)).toBe(0xfeff)
|
||||
expect(csv).toContain('Datum;Förare;Fordon;Registreringsnummer')
|
||||
})
|
||||
|
||||
it('renders decimal comma, odometer readings and status labels', () => {
|
||||
const csv = mileageTripsToCsv([trip({})])
|
||||
const row = csv.split('\r\n')[1]
|
||||
expect(row).toContain('2026-05-10;;Egen bil;ABC123;1000;1032;32,3;Kontoret;Kunden')
|
||||
expect(row).toContain('Utkast')
|
||||
})
|
||||
|
||||
it('names the driver for employee-attributed trips', () => {
|
||||
const csv = mileageTripsToCsv(
|
||||
[trip({ employee_id: 'emp-1' })],
|
||||
new Map(),
|
||||
new Map([['emp-1', 'Anna Andersson']])
|
||||
)
|
||||
expect(csv.split('\r\n')[1]).toContain('2026-05-10;Anna Andersson;Egen bil')
|
||||
})
|
||||
|
||||
it('neutralizes formula-injection triggers in user text', () => {
|
||||
const csv = mileageTripsToCsv([
|
||||
trip({ purpose: '=HYPERLINK("http://evil","x")', from_location: '+SUM(A1)', visited: '@cmd' }),
|
||||
])
|
||||
const row = csv.split('\r\n')[1]
|
||||
expect(row).toContain(`'=HYPERLINK`)
|
||||
expect(row).toContain(`'+SUM(A1)`)
|
||||
expect(row).toContain(`'@cmd`)
|
||||
})
|
||||
|
||||
it('quotes fields containing separators and escapes quotes', () => {
|
||||
const csv = mileageTripsToCsv([
|
||||
trip({ purpose: 'Möte; leverans', visited: 'Firma "AB"' }),
|
||||
])
|
||||
expect(csv).toContain('"Möte; leverans"')
|
||||
expect(csv).toContain('"Firma ""AB"""')
|
||||
})
|
||||
|
||||
it('maps journal entry ids to voucher labels for booked trips', () => {
|
||||
const csv = mileageTripsToCsv(
|
||||
[trip({ status: 'booked', journal_entry_id: 'je-1' })],
|
||||
new Map([['je-1', 'A42']])
|
||||
)
|
||||
const row = csv.split('\r\n')[1]
|
||||
expect(row).toContain('Bokförd;A42')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,449 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { PayrollConfig } from '@/lib/salary/payroll-config'
|
||||
import type { MileageTrip } from '@/types'
|
||||
|
||||
vi.mock('@/lib/supabase/fetch-all', () => ({ fetchAllRows: vi.fn() }))
|
||||
vi.mock('@/lib/bookkeeping/engine', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('@/lib/bookkeeping/engine')>()
|
||||
return { ...actual, createJournalEntry: vi.fn() }
|
||||
})
|
||||
vi.mock('@/lib/salary/payroll-config', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('@/lib/salary/payroll-config')>()
|
||||
return { ...actual, loadPayrollConfig: vi.fn() }
|
||||
})
|
||||
vi.mock('@/lib/core/bookkeeping/period-service', () => ({
|
||||
resolvePeriodStatusForDate: vi.fn(),
|
||||
}))
|
||||
|
||||
import {
|
||||
bookMileagePeriod,
|
||||
createTrip,
|
||||
pushMileageToSalaryRun,
|
||||
ratePerMil,
|
||||
summarizeTrips,
|
||||
} from '@/lib/mileage/mileage-service'
|
||||
import { fetchAllRows } from '@/lib/supabase/fetch-all'
|
||||
import { createJournalEntry } from '@/lib/bookkeeping/engine'
|
||||
import { loadPayrollConfig } from '@/lib/salary/payroll-config'
|
||||
import { resolvePeriodStatusForDate } from '@/lib/core/bookkeeping/period-service'
|
||||
|
||||
const CONFIG = {
|
||||
milersattningEgenBil: 25,
|
||||
milersattningFormansbilFossil: 12,
|
||||
milersattningFormansbilEl: 9.5,
|
||||
} as PayrollConfig
|
||||
|
||||
function trip(overrides: Partial<MileageTrip>): MileageTrip {
|
||||
return {
|
||||
id: 'trip-1',
|
||||
company_id: 'company-1',
|
||||
user_id: 'user-1',
|
||||
employee_id: null,
|
||||
trip_date: '2026-05-10',
|
||||
vehicle_type: 'own_car',
|
||||
vehicle_registration: null,
|
||||
odometer_start: null,
|
||||
odometer_end: null,
|
||||
distance_km: 100,
|
||||
from_location: 'Kontoret',
|
||||
to_location: 'Kunden',
|
||||
purpose: 'Kundbesök',
|
||||
visited: null,
|
||||
is_round_trip: false,
|
||||
status: 'draft',
|
||||
journal_entry_id: null,
|
||||
salary_run_id: null,
|
||||
notes: null,
|
||||
created_via: 'manual',
|
||||
created_at: '2026-05-10T00:00:00Z',
|
||||
updated_at: '2026-05-10T00:00:00Z',
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
describe('ratePerMil', () => {
|
||||
it('maps every vehicle type to its config rate', () => {
|
||||
expect(ratePerMil(CONFIG, 'own_car')).toBe(25)
|
||||
expect(ratePerMil(CONFIG, 'company_car_fossil')).toBe(12)
|
||||
expect(ratePerMil(CONFIG, 'company_car_electric')).toBe(9.5)
|
||||
})
|
||||
})
|
||||
|
||||
describe('summarizeTrips', () => {
|
||||
it('converts km to mil and applies the schablon rate', () => {
|
||||
const [summary] = summarizeTrips([trip({ distance_km: 100 })], CONFIG)
|
||||
expect(summary).toMatchObject({
|
||||
vehicle_type: 'own_car',
|
||||
trip_count: 1,
|
||||
total_km: 100,
|
||||
total_mil: 10,
|
||||
rate_per_mil: 25,
|
||||
amount: 250,
|
||||
})
|
||||
})
|
||||
|
||||
it('keeps öre precision without drift (32.3 km → 80.75 kr)', () => {
|
||||
const [summary] = summarizeTrips([trip({ distance_km: 32.3 })], CONFIG)
|
||||
expect(summary.total_mil).toBe(3.23)
|
||||
expect(summary.amount).toBe(80.75)
|
||||
})
|
||||
|
||||
it('groups by vehicle type and sums per group', () => {
|
||||
const summaries = summarizeTrips(
|
||||
[
|
||||
trip({ distance_km: 40 }),
|
||||
trip({ distance_km: 60 }),
|
||||
trip({ distance_km: 50, vehicle_type: 'company_car_electric' }),
|
||||
],
|
||||
CONFIG
|
||||
)
|
||||
expect(summaries).toHaveLength(2)
|
||||
const own = summaries.find((s) => s.vehicle_type === 'own_car')
|
||||
const el = summaries.find((s) => s.vehicle_type === 'company_car_electric')
|
||||
expect(own).toMatchObject({ trip_count: 2, total_km: 100, amount: 250 })
|
||||
expect(el).toMatchObject({ trip_count: 1, total_km: 50, amount: 47.5 })
|
||||
})
|
||||
|
||||
it('tolerates numeric-as-string distances from Postgres', () => {
|
||||
const [summary] = summarizeTrips(
|
||||
[trip({ distance_km: '12.5' as unknown as number })],
|
||||
CONFIG
|
||||
)
|
||||
expect(summary.total_km).toBe(12.5)
|
||||
expect(summary.amount).toBe(31.25)
|
||||
})
|
||||
})
|
||||
|
||||
describe('createTrip', () => {
|
||||
it('rejects a förmånsbil trip without vehicle_registration before any write', async () => {
|
||||
const supabase = { from: vi.fn() }
|
||||
await expect(
|
||||
createTrip(supabase as never, 'company-1', 'user-1', {
|
||||
trip_date: '2026-05-10',
|
||||
vehicle_type: 'company_car_electric',
|
||||
distance_km: 10,
|
||||
from_location: 'A',
|
||||
to_location: 'B',
|
||||
purpose: 'Kundbesök',
|
||||
})
|
||||
).rejects.toThrow(/registreringsnummer/)
|
||||
expect(supabase.from).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('pushMileageToSalaryRun', () => {
|
||||
const params = {
|
||||
runId: 'run-1',
|
||||
employeeId: 'emp-1',
|
||||
from: '2026-05-01',
|
||||
to: '2026-05-31',
|
||||
}
|
||||
|
||||
function salarySupabase(opts: {
|
||||
run?: { id: string; status: string } | null
|
||||
sre?: { id: string } | null
|
||||
claimIds?: string[]
|
||||
itemError?: { message: string } | null
|
||||
}) {
|
||||
const insert = vi.fn(() => Promise.resolve({ error: opts.itemError ?? null }))
|
||||
const tripChain: Record<string, unknown> = {}
|
||||
for (const method of ['update', 'eq', 'in', 'is']) {
|
||||
tripChain[method] = vi.fn(() => tripChain)
|
||||
}
|
||||
tripChain.select = vi.fn(() =>
|
||||
Promise.resolve({ data: (opts.claimIds ?? []).map((id) => ({ id })), error: null })
|
||||
)
|
||||
tripChain.then = (resolve: (v: unknown) => unknown) =>
|
||||
Promise.resolve({ data: null, error: null }).then(resolve)
|
||||
|
||||
const singleChain = (row: unknown) => {
|
||||
const chain: Record<string, unknown> = {}
|
||||
for (const method of ['select', 'eq']) {
|
||||
chain[method] = vi.fn(() => chain)
|
||||
}
|
||||
chain.single = vi.fn(() => Promise.resolve({ data: row, error: row ? null : {} }))
|
||||
return chain
|
||||
}
|
||||
|
||||
return {
|
||||
from: vi.fn((table: string) => {
|
||||
if (table === 'salary_runs') return singleChain(opts.run ?? null)
|
||||
if (table === 'salary_run_employees') return singleChain(opts.sre ?? null)
|
||||
if (table === 'salary_line_items') return { insert }
|
||||
return tripChain
|
||||
}),
|
||||
insert,
|
||||
tripChain,
|
||||
}
|
||||
}
|
||||
|
||||
it('maps missing run / wrong status / missing employee to their codes', async () => {
|
||||
const missingRun = salarySupabase({ run: null })
|
||||
expect(
|
||||
await pushMileageToSalaryRun(missingRun as never, 'company-1', params)
|
||||
).toEqual({ ok: false, code: 'RUN_NOT_FOUND' })
|
||||
|
||||
const bookedRun = salarySupabase({ run: { id: 'run-1', status: 'booked' } })
|
||||
expect(
|
||||
await pushMileageToSalaryRun(bookedRun as never, 'company-1', params)
|
||||
).toEqual({ ok: false, code: 'RUN_NOT_EDITABLE' })
|
||||
|
||||
const noSre = salarySupabase({ run: { id: 'run-1', status: 'draft' }, sre: null })
|
||||
expect(
|
||||
await pushMileageToSalaryRun(noSre as never, 'company-1', params)
|
||||
).toEqual({ ok: false, code: 'EMPLOYEE_NOT_IN_RUN' })
|
||||
})
|
||||
|
||||
it('claims trips BEFORE inserting line items and inserts kostnadsersättning flags', async () => {
|
||||
vi.mocked(fetchAllRows).mockResolvedValue([
|
||||
trip({ id: 't1', employee_id: 'emp-1', distance_km: 100 }),
|
||||
])
|
||||
vi.mocked(loadPayrollConfig).mockResolvedValue(CONFIG)
|
||||
const supabase = salarySupabase({
|
||||
run: { id: 'run-1', status: 'draft' },
|
||||
sre: { id: 'sre-1' },
|
||||
claimIds: ['t1'],
|
||||
})
|
||||
|
||||
const result = await pushMileageToSalaryRun(supabase as never, 'company-1', params)
|
||||
expect(result).toMatchObject({ ok: true, tripCount: 1, totalAmount: 250 })
|
||||
const item = (supabase.insert.mock.calls[0] as unknown[][])[0][0]
|
||||
expect(item).toMatchObject({
|
||||
item_type: 'mileage_taxfree',
|
||||
amount: 250,
|
||||
is_taxable: false,
|
||||
is_avgift_basis: false,
|
||||
is_vacation_basis: false,
|
||||
account_number: '7331',
|
||||
})
|
||||
// The claim ran before the insert (retry cannot double-pay).
|
||||
const claimOrder = (supabase.tripChain.update as ReturnType<typeof vi.fn>).mock
|
||||
.invocationCallOrder[0]
|
||||
const insertOrder = supabase.insert.mock.invocationCallOrder[0]
|
||||
expect(claimOrder).toBeLessThan(insertOrder)
|
||||
})
|
||||
|
||||
it('returns CLAIM_LOST and reverts when another booking claimed first', async () => {
|
||||
vi.mocked(fetchAllRows).mockResolvedValue([
|
||||
trip({ id: 't1', employee_id: 'emp-1' }),
|
||||
trip({ id: 't2', employee_id: 'emp-1' }),
|
||||
])
|
||||
vi.mocked(loadPayrollConfig).mockResolvedValue(CONFIG)
|
||||
const supabase = salarySupabase({
|
||||
run: { id: 'run-1', status: 'draft' },
|
||||
sre: { id: 'sre-1' },
|
||||
claimIds: ['t1'],
|
||||
})
|
||||
const result = await pushMileageToSalaryRun(supabase as never, 'company-1', params)
|
||||
expect(result).toEqual({ ok: false, code: 'CLAIM_LOST' })
|
||||
expect(supabase.insert).not.toHaveBeenCalled()
|
||||
// The partial claim was reverted, not left dangling.
|
||||
expect(supabase.tripChain.update).toHaveBeenCalledWith({
|
||||
status: 'draft',
|
||||
salary_run_id: null,
|
||||
})
|
||||
})
|
||||
|
||||
it('reverts the claim when the line item insert fails', async () => {
|
||||
vi.mocked(fetchAllRows).mockResolvedValue([trip({ id: 't1', employee_id: 'emp-1' })])
|
||||
vi.mocked(loadPayrollConfig).mockResolvedValue(CONFIG)
|
||||
const supabase = salarySupabase({
|
||||
run: { id: 'run-1', status: 'draft' },
|
||||
sre: { id: 'sre-1' },
|
||||
claimIds: ['t1'],
|
||||
itemError: { message: 'insert failed' },
|
||||
})
|
||||
await expect(
|
||||
pushMileageToSalaryRun(supabase as never, 'company-1', params)
|
||||
).rejects.toThrow('insert failed')
|
||||
expect(supabase.tripChain.update).toHaveBeenCalledWith({ status: 'draft', salary_run_id: null })
|
||||
})
|
||||
})
|
||||
|
||||
describe('bookMileagePeriod', () => {
|
||||
const params = {
|
||||
from: '2026-05-01',
|
||||
to: '2026-05-31',
|
||||
entryDate: '2026-05-31',
|
||||
counterAccount: '2820' as const,
|
||||
}
|
||||
|
||||
// Queued mock: each .select() call consumes the next result (claim first,
|
||||
// then the journal_entry_id link). The orphan sweep and revert paths await
|
||||
// the chain without .select(), so the chain itself is thenable.
|
||||
function stampSupabase(selectResults: string[][]) {
|
||||
const queue = [...selectResults]
|
||||
const chain: Record<string, unknown> = {}
|
||||
for (const method of ['update', 'eq', 'in', 'is', 'lt', 'gte', 'lte', 'maybeSingle']) {
|
||||
chain[method] = vi.fn(() => chain)
|
||||
}
|
||||
chain.select = vi.fn(() => {
|
||||
const ids = queue.shift() ?? []
|
||||
return Promise.resolve({ data: ids.map((id) => ({ id })), error: null })
|
||||
})
|
||||
chain.then = (resolve: (v: unknown) => unknown) =>
|
||||
Promise.resolve({ data: null, error: null }).then(resolve)
|
||||
return { from: vi.fn(() => chain), chain }
|
||||
}
|
||||
|
||||
it('returns NO_TRIPS when the period has no drafts', async () => {
|
||||
vi.mocked(fetchAllRows).mockResolvedValue([])
|
||||
const result = await bookMileagePeriod(
|
||||
stampSupabase([]) as never,
|
||||
'company-1',
|
||||
'user-1',
|
||||
params
|
||||
)
|
||||
expect(result).toEqual({ ok: false, code: 'NO_TRIPS' })
|
||||
expect(createJournalEntry).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('loses a concurrent race cleanly: partial claim reverts as CLAIM_LOST', async () => {
|
||||
vi.mocked(fetchAllRows).mockResolvedValue([trip({ id: 't1' }), trip({ id: 't2' })])
|
||||
vi.mocked(resolvePeriodStatusForDate).mockResolvedValue({
|
||||
status: 'open',
|
||||
period_id: 'period-1',
|
||||
} as never)
|
||||
vi.mocked(loadPayrollConfig).mockResolvedValue(CONFIG)
|
||||
|
||||
// Another booking claimed t2 first: our claim only gets t1.
|
||||
const supabase = stampSupabase([['t1']])
|
||||
const result = await bookMileagePeriod(supabase as never, 'company-1', 'user-1', params)
|
||||
expect(result).toEqual({ ok: false, code: 'CLAIM_LOST' })
|
||||
expect(createJournalEntry).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('fails as TRIPS_CHANGED when the staged trip set drifted before approval', async () => {
|
||||
vi.mocked(fetchAllRows).mockResolvedValue([trip({ id: 't1' }), trip({ id: 't3' })])
|
||||
const supabase = stampSupabase([])
|
||||
const result = await bookMileagePeriod(supabase as never, 'company-1', 'user-1', {
|
||||
...params,
|
||||
expectedTripIds: ['t1', 't2'],
|
||||
})
|
||||
expect(result).toEqual({ ok: false, code: 'TRIPS_CHANGED' })
|
||||
expect(createJournalEntry).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('rejects a period spanning calendar years', async () => {
|
||||
const supabase = stampSupabase([])
|
||||
await expect(
|
||||
bookMileagePeriod(supabase as never, 'company-1', 'user-1', {
|
||||
...params,
|
||||
from: '2025-12-20',
|
||||
to: '2026-01-10',
|
||||
})
|
||||
).rejects.toThrow(/kalenderår/)
|
||||
})
|
||||
|
||||
it('refuses a period spanning several employees (BFL motpart)', async () => {
|
||||
vi.mocked(fetchAllRows).mockResolvedValue([
|
||||
trip({ id: 't1', employee_id: 'emp-1' }),
|
||||
trip({ id: 't2', employee_id: null }),
|
||||
])
|
||||
const result = await bookMileagePeriod(
|
||||
stampSupabase([]) as never,
|
||||
'company-1',
|
||||
'user-1',
|
||||
params
|
||||
)
|
||||
expect(result).toEqual({ ok: false, code: 'MIXED_EMPLOYEES' })
|
||||
expect(createJournalEntry).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('returns PERIOD_NOT_OPEN without writing when the entry date is locked', async () => {
|
||||
vi.mocked(fetchAllRows).mockResolvedValue([trip({})])
|
||||
vi.mocked(resolvePeriodStatusForDate).mockResolvedValue({
|
||||
status: 'locked',
|
||||
period_id: 'p1',
|
||||
} as never)
|
||||
const result = await bookMileagePeriod(
|
||||
stampSupabase([]) as never,
|
||||
'company-1',
|
||||
'user-1',
|
||||
params
|
||||
)
|
||||
expect(result).toEqual({ ok: false, code: 'PERIOD_NOT_OPEN' })
|
||||
expect(createJournalEntry).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('books one balanced verifikat and stamps the trips', async () => {
|
||||
const trips = [
|
||||
trip({ id: 't1', distance_km: 100 }),
|
||||
trip({ id: 't2', distance_km: 50, vehicle_type: 'company_car_electric' }),
|
||||
]
|
||||
vi.mocked(fetchAllRows).mockResolvedValue(trips)
|
||||
vi.mocked(resolvePeriodStatusForDate).mockResolvedValue({
|
||||
status: 'open',
|
||||
period_id: 'period-1',
|
||||
} as never)
|
||||
vi.mocked(loadPayrollConfig).mockResolvedValue(CONFIG)
|
||||
vi.mocked(createJournalEntry).mockResolvedValue({
|
||||
id: 'je-1',
|
||||
voucher_number: 42,
|
||||
voucher_series: 'A',
|
||||
} as never)
|
||||
|
||||
const supabase = stampSupabase([['t1', 't2'], ['t1', 't2']])
|
||||
const result = await bookMileagePeriod(supabase as never, 'company-1', 'user-1', params)
|
||||
|
||||
expect(result).toMatchObject({
|
||||
ok: true,
|
||||
journalEntryId: 'je-1',
|
||||
voucherNumber: 42,
|
||||
tripCount: 2,
|
||||
totalAmount: 297.5,
|
||||
})
|
||||
|
||||
const input = vi.mocked(createJournalEntry).mock.calls[0][3]
|
||||
expect(input.fiscal_period_id).toBe('period-1')
|
||||
expect(input.source_type).toBe('manual')
|
||||
const debits = input.lines.filter((l) => l.debit_amount > 0)
|
||||
const credit = input.lines.find((l) => l.credit_amount > 0)
|
||||
expect(debits).toHaveLength(2)
|
||||
expect(debits.every((l) => l.account_number === '7331')).toBe(true)
|
||||
expect(credit?.account_number).toBe('2820')
|
||||
const totalDebit = debits.reduce((sum, l) => sum + l.debit_amount, 0)
|
||||
expect(Math.round(totalDebit * 100) / 100).toBe(credit?.credit_amount)
|
||||
})
|
||||
|
||||
it('surfaces STAMP_FAILED with the entry id when the entry link mismatches', async () => {
|
||||
vi.mocked(fetchAllRows).mockResolvedValue([
|
||||
trip({ id: 't1' }),
|
||||
trip({ id: 't2' }),
|
||||
])
|
||||
vi.mocked(resolvePeriodStatusForDate).mockResolvedValue({
|
||||
status: 'open',
|
||||
period_id: 'period-1',
|
||||
} as never)
|
||||
vi.mocked(loadPayrollConfig).mockResolvedValue(CONFIG)
|
||||
vi.mocked(createJournalEntry).mockResolvedValue({ id: 'je-1' } as never)
|
||||
|
||||
// Claim succeeds for both trips; the journal_entry_id backfill only lands
|
||||
// on one row.
|
||||
const supabase = stampSupabase([['t1', 't2'], ['t1']])
|
||||
const result = await bookMileagePeriod(supabase as never, 'company-1', 'user-1', params)
|
||||
expect(result).toEqual({ ok: false, code: 'STAMP_FAILED', journalEntryId: 'je-1' })
|
||||
})
|
||||
|
||||
it('reverts the claim when verifikat creation fails', async () => {
|
||||
vi.mocked(fetchAllRows).mockResolvedValue([trip({ id: 't1' })])
|
||||
vi.mocked(resolvePeriodStatusForDate).mockResolvedValue({
|
||||
status: 'open',
|
||||
period_id: 'period-1',
|
||||
} as never)
|
||||
vi.mocked(loadPayrollConfig).mockResolvedValue(CONFIG)
|
||||
vi.mocked(createJournalEntry).mockRejectedValue(new Error('period locked'))
|
||||
|
||||
const supabase = stampSupabase([['t1']])
|
||||
await expect(
|
||||
bookMileagePeriod(supabase as never, 'company-1', 'user-1', params)
|
||||
).rejects.toThrow('period locked')
|
||||
// Claim + revert both went through the update chain.
|
||||
expect(supabase.chain.update).toHaveBeenCalledWith({ status: 'booked' })
|
||||
expect(supabase.chain.update).toHaveBeenCalledWith({ status: 'draft' })
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,76 @@
|
||||
import type { MileageTrip } from '@/types'
|
||||
|
||||
/**
|
||||
* Körjournal CSV export for Skatteverket audit purposes. Column labels are
|
||||
* statutory-adjacent Swedish terms and stay Swedish in both locales (same
|
||||
* policy as SIE/INK2 exports). Semicolon-separated with a UTF-8 BOM so
|
||||
* Swedish Excel opens it correctly.
|
||||
*/
|
||||
|
||||
const HEADERS = [
|
||||
'Datum',
|
||||
'Förare',
|
||||
'Fordon',
|
||||
'Registreringsnummer',
|
||||
'Mätarställning start',
|
||||
'Mätarställning slut',
|
||||
'Antal km',
|
||||
'Från',
|
||||
'Till',
|
||||
'Ärende',
|
||||
'Besökt (kund/plats)',
|
||||
'Tur och retur',
|
||||
'Status',
|
||||
'Verifikat',
|
||||
] as const
|
||||
|
||||
const VEHICLE_LABELS: Record<MileageTrip['vehicle_type'], string> = {
|
||||
own_car: 'Egen bil',
|
||||
company_car_fossil: 'Förmånsbil (bensin/diesel)',
|
||||
company_car_electric: 'Förmånsbil (el)',
|
||||
}
|
||||
|
||||
function csvField(value: string | number | null | undefined): string {
|
||||
if (value === null || value === undefined || value === '') return ''
|
||||
let text = String(value)
|
||||
// Formula-injection guard (OWASP CSV injection): user-entered text starting
|
||||
// with a formula trigger would execute when the export opens in Excel.
|
||||
// Neutralize with a leading apostrophe; spreadsheet apps hide it.
|
||||
if (/^[=+@\t\r-]/.test(text)) {
|
||||
text = `'${text}`
|
||||
}
|
||||
if (/[";\n\r]/.test(text)) {
|
||||
return `"${text.replace(/"/g, '""')}"`
|
||||
}
|
||||
return text
|
||||
}
|
||||
|
||||
export function mileageTripsToCsv(
|
||||
trips: MileageTrip[],
|
||||
voucherLabels: Map<string, string> = new Map(),
|
||||
driverLabels: Map<string, string> = new Map()
|
||||
): string {
|
||||
const rows = trips.map((trip) =>
|
||||
[
|
||||
trip.trip_date,
|
||||
trip.employee_id ? (driverLabels.get(trip.employee_id) ?? '') : '',
|
||||
VEHICLE_LABELS[trip.vehicle_type],
|
||||
trip.vehicle_registration,
|
||||
trip.odometer_start,
|
||||
trip.odometer_end,
|
||||
// Swedish decimal comma for Excel.
|
||||
String(trip.distance_km).replace('.', ','),
|
||||
trip.from_location,
|
||||
trip.to_location,
|
||||
trip.purpose,
|
||||
trip.visited,
|
||||
trip.is_round_trip ? 'Ja' : 'Nej',
|
||||
trip.status === 'booked' ? 'Bokförd' : 'Utkast',
|
||||
trip.journal_entry_id ? (voucherLabels.get(trip.journal_entry_id) ?? '') : '',
|
||||
]
|
||||
.map(csvField)
|
||||
.join(';')
|
||||
)
|
||||
|
||||
return '\uFEFF' + [HEADERS.join(';'), ...rows].join('\r\n') + '\r\n'
|
||||
}
|
||||
@@ -0,0 +1,499 @@
|
||||
import type { SupabaseClient } from '@supabase/supabase-js'
|
||||
import type {
|
||||
CreateMileageTripInput,
|
||||
MileagePeriodSummary,
|
||||
MileageTrip,
|
||||
MileageVehicleType,
|
||||
} from '@/types'
|
||||
import { createJournalEntry } from '@/lib/bookkeeping/engine'
|
||||
import { loadPayrollConfig, type PayrollConfig } from '@/lib/salary/payroll-config'
|
||||
import { getLineItemAccount } from '@/lib/salary/account-mapping'
|
||||
import { fetchAllRows } from '@/lib/supabase/fetch-all'
|
||||
import { resolvePeriodStatusForDate } from '@/lib/core/bookkeeping/period-service'
|
||||
import { roundOre } from '@/lib/money'
|
||||
|
||||
/**
|
||||
* Körjournal service: trip log per Skatteverket documentation requirements
|
||||
* and milersättning booking.
|
||||
*
|
||||
* Rates come from the DB-driven payroll config (salary_payroll_config), never
|
||||
* hardcoded. V1 always reimburses at exactly the tax-free schablon, so no
|
||||
* taxable excess arises; the 7332 path exists in the salary module for
|
||||
* companies that pay above schablon through payroll.
|
||||
*/
|
||||
|
||||
const KM_PER_MIL = 10
|
||||
|
||||
/** BAS 7331: skattefria bilersättningar. */
|
||||
const MILEAGE_TAXFREE_ACCOUNT = getLineItemAccount('mileage_taxfree')
|
||||
|
||||
/** Counter accounts a mileage verifikat may credit. */
|
||||
export const MILEAGE_COUNTER_ACCOUNTS = ['2820', '2893', '1930'] as const
|
||||
export type MileageCounterAccount = (typeof MILEAGE_COUNTER_ACCOUNTS)[number]
|
||||
|
||||
const round2 = roundOre
|
||||
|
||||
export function ratePerMil(config: PayrollConfig, vehicleType: MileageVehicleType): number {
|
||||
switch (vehicleType) {
|
||||
case 'own_car':
|
||||
return config.milersattningEgenBil
|
||||
case 'company_car_fossil':
|
||||
return config.milersattningFormansbilFossil
|
||||
case 'company_car_electric':
|
||||
return config.milersattningFormansbilEl
|
||||
}
|
||||
}
|
||||
|
||||
const VEHICLE_TYPE_LABELS: Record<MileageVehicleType, string> = {
|
||||
own_car: 'egen bil',
|
||||
company_car_fossil: 'förmånsbil (bensin/diesel)',
|
||||
company_car_electric: 'förmånsbil (el)',
|
||||
}
|
||||
|
||||
/**
|
||||
* Aggregate trips into per-vehicle-type totals at the schablon rate.
|
||||
* Amounts are rounded once per vehicle-type group (cents-integer math),
|
||||
* so the group amounts sum exactly to the verifikat total.
|
||||
*/
|
||||
export function summarizeTrips(
|
||||
trips: Pick<MileageTrip, 'vehicle_type' | 'distance_km'>[],
|
||||
config: PayrollConfig
|
||||
): MileagePeriodSummary[] {
|
||||
const groups = new Map<MileageVehicleType, { km: number; count: number }>()
|
||||
for (const trip of trips) {
|
||||
const group = groups.get(trip.vehicle_type) || { km: 0, count: 0 }
|
||||
group.km = round2(group.km + Number(trip.distance_km))
|
||||
group.count += 1
|
||||
groups.set(trip.vehicle_type, group)
|
||||
}
|
||||
|
||||
const summaries: MileagePeriodSummary[] = []
|
||||
for (const [vehicleType, group] of groups) {
|
||||
const mil = round2(group.km / KM_PER_MIL)
|
||||
const rate = ratePerMil(config, vehicleType)
|
||||
summaries.push({
|
||||
vehicle_type: vehicleType,
|
||||
trip_count: group.count,
|
||||
total_km: group.km,
|
||||
total_mil: mil,
|
||||
rate_per_mil: rate,
|
||||
amount: round2(mil * rate),
|
||||
})
|
||||
}
|
||||
return summaries.sort((a, b) => a.vehicle_type.localeCompare(b.vehicle_type))
|
||||
}
|
||||
|
||||
export interface ListTripsFilter {
|
||||
from?: string
|
||||
to?: string
|
||||
status?: 'draft' | 'booked'
|
||||
employeeId?: string
|
||||
}
|
||||
|
||||
export async function listTrips(
|
||||
supabase: SupabaseClient,
|
||||
companyId: string,
|
||||
filter: ListTripsFilter = {}
|
||||
): Promise<MileageTrip[]> {
|
||||
return fetchAllRows<MileageTrip>(({ from, to }) => {
|
||||
let query = supabase
|
||||
.from('mileage_trips')
|
||||
.select('*')
|
||||
.eq('company_id', companyId)
|
||||
.order('trip_date', { ascending: false })
|
||||
.order('created_at', { ascending: false })
|
||||
.range(from, to)
|
||||
if (filter.from) query = query.gte('trip_date', filter.from)
|
||||
if (filter.to) query = query.lte('trip_date', filter.to)
|
||||
if (filter.status) query = query.eq('status', filter.status)
|
||||
if (filter.employeeId) query = query.eq('employee_id', filter.employeeId)
|
||||
return query
|
||||
})
|
||||
}
|
||||
|
||||
export async function createTrip(
|
||||
supabase: SupabaseClient,
|
||||
companyId: string,
|
||||
userId: string,
|
||||
input: CreateMileageTripInput
|
||||
): Promise<MileageTrip> {
|
||||
// A körjournal for a förmånsbil must identify the vehicle: the schablon
|
||||
// rate depends on which car was driven, and Skatteverket expects the
|
||||
// underlag to name it. Own-car trips may omit it (single private vehicle).
|
||||
if ((input.vehicle_type ?? 'own_car') !== 'own_car' && !input.vehicle_registration?.trim()) {
|
||||
throw new Error('Ange registreringsnummer för förmånsbilen')
|
||||
}
|
||||
// The FK on employee_id is not company-scoped; without this check a
|
||||
// cross-company employee UUID would attach silently.
|
||||
if (input.employee_id) {
|
||||
const { data: employee } = await supabase
|
||||
.from('employees')
|
||||
.select('id')
|
||||
.eq('company_id', companyId)
|
||||
.eq('id', input.employee_id)
|
||||
.maybeSingle()
|
||||
if (!employee) {
|
||||
throw new Error('Den anställda hittades inte i företaget')
|
||||
}
|
||||
}
|
||||
const { data, error } = await supabase
|
||||
.from('mileage_trips')
|
||||
.insert({
|
||||
company_id: companyId,
|
||||
user_id: userId,
|
||||
employee_id: input.employee_id || null,
|
||||
trip_date: input.trip_date,
|
||||
vehicle_type: input.vehicle_type || 'own_car',
|
||||
vehicle_registration: input.vehicle_registration?.trim() || null,
|
||||
odometer_start: input.odometer_start ?? null,
|
||||
odometer_end: input.odometer_end ?? null,
|
||||
// The column is numeric(10,1): round to what will actually be stored.
|
||||
distance_km: Math.round(input.distance_km * 10) / 10,
|
||||
from_location: input.from_location.trim(),
|
||||
to_location: input.to_location.trim(),
|
||||
purpose: input.purpose.trim(),
|
||||
visited: input.visited?.trim() || null,
|
||||
is_round_trip: input.is_round_trip ?? false,
|
||||
notes: input.notes?.trim() || null,
|
||||
created_via: input.created_via || 'manual',
|
||||
})
|
||||
.select()
|
||||
.single()
|
||||
|
||||
if (error || !data) {
|
||||
throw new Error(`Failed to create mileage trip: ${error?.message ?? 'no row returned'}`)
|
||||
}
|
||||
return data as MileageTrip
|
||||
}
|
||||
|
||||
export type BookMileageResult =
|
||||
| {
|
||||
ok: true
|
||||
journalEntryId: string
|
||||
voucherNumber: number | null
|
||||
voucherSeries: string | null
|
||||
tripCount: number
|
||||
totalAmount: number
|
||||
summaries: MileagePeriodSummary[]
|
||||
}
|
||||
| {
|
||||
ok: false
|
||||
code:
|
||||
| 'NO_TRIPS'
|
||||
| 'MIXED_EMPLOYEES'
|
||||
| 'PERIOD_NOT_OPEN'
|
||||
| 'CLAIM_LOST'
|
||||
| 'TRIPS_CHANGED'
|
||||
| 'STAMP_FAILED'
|
||||
journalEntryId?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Book all draft trips in [from, to] as one milersättning verifikat:
|
||||
* debit 7331 per vehicle type, credit the chosen counter account
|
||||
* (2820 skuld till anställda, 2893 avräkning aktieägare, or 1930 when the
|
||||
* payout already left the bank). Trips are stamped booked + linked to the
|
||||
* verifikat afterwards; the trip rows are the körjournal underlag (7-year
|
||||
* retention via DB trigger).
|
||||
*/
|
||||
export async function bookMileagePeriod(
|
||||
supabase: SupabaseClient,
|
||||
companyId: string,
|
||||
userId: string,
|
||||
params: {
|
||||
from: string
|
||||
to: string
|
||||
counterAccount: MileageCounterAccount
|
||||
entryDate: string
|
||||
employeeId?: string
|
||||
createdVia?: 'manual' | 'mcp'
|
||||
/**
|
||||
* When set (staged MCP approvals), the commit only proceeds if the
|
||||
* current draft-trip set matches exactly what was previewed at staging
|
||||
* time: otherwise the approved amount and the booked amount could drift.
|
||||
*/
|
||||
expectedTripIds?: string[]
|
||||
}
|
||||
): Promise<BookMileageResult> {
|
||||
// Rates are per calendar year; a period spanning a year boundary would book
|
||||
// every trip at one year's schablon. Callers book per year (schema-enforced
|
||||
// in the API; belt-and-braces here for direct service callers).
|
||||
if (params.from.slice(0, 4) !== params.to.slice(0, 4)) {
|
||||
throw new Error('Milersättning bokförs per kalenderår: dela upp perioden per år')
|
||||
}
|
||||
|
||||
// Release orphaned claims from a crashed earlier booking (status booked,
|
||||
// no verifikat, no salary run) so their trips become bookable again. The
|
||||
// 5-minute age guard keeps a concurrent in-flight booking's claim safe.
|
||||
const staleBefore = new Date(Date.now() - 5 * 60 * 1000).toISOString()
|
||||
await supabase
|
||||
.from('mileage_trips')
|
||||
.update({ status: 'draft' })
|
||||
.eq('company_id', companyId)
|
||||
.eq('status', 'booked')
|
||||
.is('journal_entry_id', null)
|
||||
.is('salary_run_id', null)
|
||||
.lt('updated_at', staleBefore)
|
||||
.gte('trip_date', params.from)
|
||||
.lte('trip_date', params.to)
|
||||
|
||||
const trips = await listTrips(supabase, companyId, {
|
||||
from: params.from,
|
||||
to: params.to,
|
||||
status: 'draft',
|
||||
employeeId: params.employeeId,
|
||||
})
|
||||
if (trips.length === 0) {
|
||||
return { ok: false, code: 'NO_TRIPS' }
|
||||
}
|
||||
|
||||
if (params.expectedTripIds) {
|
||||
const expected = new Set(params.expectedTripIds)
|
||||
const actual = new Set(trips.map((t) => t.id))
|
||||
const sameSet =
|
||||
expected.size === actual.size && [...expected].every((id) => actual.has(id))
|
||||
if (!sameSet) {
|
||||
return { ok: false, code: 'TRIPS_CHANGED' }
|
||||
}
|
||||
}
|
||||
|
||||
// BFL 5 kap 6-7 §: the verifikat must identify its motpart. A single lump
|
||||
// credit on 2820 covering several employees' reimbursements loses that, so
|
||||
// a period spanning more than one employee (unassigned trips count as the
|
||||
// owner's) must be booked per employee via the employeeId filter.
|
||||
const distinctEmployees = new Set(trips.map((t) => t.employee_id ?? 'unassigned'))
|
||||
if (distinctEmployees.size > 1) {
|
||||
return { ok: false, code: 'MIXED_EMPLOYEES' }
|
||||
}
|
||||
|
||||
const period = await resolvePeriodStatusForDate(supabase, companyId, params.entryDate)
|
||||
if (period.status !== 'open' || !period.period_id) {
|
||||
return { ok: false, code: 'PERIOD_NOT_OPEN' }
|
||||
}
|
||||
|
||||
// Date-only strings parse as UTC midnight; getFullYear() reads local time
|
||||
// and lands in the previous year for January dates in negative UTC offsets.
|
||||
const config = await loadPayrollConfig(supabase, Number(params.to.slice(0, 4)))
|
||||
const summaries = summarizeTrips(trips, config)
|
||||
const totalAmount = round2(summaries.reduce((sum, s) => sum + s.amount, 0))
|
||||
const totalMil = round2(summaries.reduce((sum, s) => sum + s.total_mil, 0))
|
||||
|
||||
// Name the motpart in the verifikationstext when the period is scoped to an
|
||||
// employee (BFL 5 kap 7 §): the trip rows carry the id, the verifikat the name.
|
||||
let motpart = ''
|
||||
const employeeId = params.employeeId ?? trips[0].employee_id
|
||||
if (employeeId) {
|
||||
const { data: employee } = await supabase
|
||||
.from('employees')
|
||||
.select('first_name, last_name')
|
||||
.eq('company_id', companyId)
|
||||
.eq('id', employeeId)
|
||||
.maybeSingle()
|
||||
if (employee) motpart = `, ${employee.first_name} ${employee.last_name}`
|
||||
}
|
||||
|
||||
// Claim the trips BEFORE creating the verifikat: the draft→booked CAS is
|
||||
// what makes a concurrent second booking (double-click, retry, two users)
|
||||
// lose the race instead of producing a duplicate verifikat for the same
|
||||
// trips. A partially lost race (someone claimed a subset first) aborts and
|
||||
// reverts rather than booking a set nobody previewed.
|
||||
const tripIds = trips.map((t) => t.id)
|
||||
const { data: claimed, error: claimError } = await supabase
|
||||
.from('mileage_trips')
|
||||
.update({ status: 'booked' })
|
||||
.eq('company_id', companyId)
|
||||
.eq('status', 'draft')
|
||||
.in('id', tripIds)
|
||||
.select('id')
|
||||
|
||||
if (claimError) {
|
||||
throw new Error(`Failed to claim mileage trips: ${claimError.message}`)
|
||||
}
|
||||
const claimedIds = (claimed ?? []).map((row) => row.id as string)
|
||||
const revertClaim = async () => {
|
||||
if (claimedIds.length === 0) return
|
||||
await supabase
|
||||
.from('mileage_trips')
|
||||
.update({ status: 'draft' })
|
||||
.eq('company_id', companyId)
|
||||
.eq('status', 'booked')
|
||||
.is('journal_entry_id', null)
|
||||
.in('id', claimedIds)
|
||||
}
|
||||
if (claimedIds.length !== tripIds.length) {
|
||||
// A concurrent booking claimed part of the set first: distinct from
|
||||
// "nothing to book" so the caller can say "reload and retry".
|
||||
await revertClaim()
|
||||
return { ok: false, code: 'CLAIM_LOST' }
|
||||
}
|
||||
|
||||
let entry
|
||||
try {
|
||||
entry = await createJournalEntry(supabase, companyId, userId, {
|
||||
fiscal_period_id: period.period_id,
|
||||
entry_date: params.entryDate,
|
||||
description: `Milersättning ${params.from} till ${params.to} (${trips.length} resor, ${totalMil} mil${motpart})`,
|
||||
source_type: 'manual',
|
||||
lines: [
|
||||
...summaries.map((s) => ({
|
||||
account_number: MILEAGE_TAXFREE_ACCOUNT,
|
||||
debit_amount: s.amount,
|
||||
credit_amount: 0,
|
||||
line_description: `Milersättning ${VEHICLE_TYPE_LABELS[s.vehicle_type]}: ${s.total_mil} mil × ${s.rate_per_mil} kr`,
|
||||
})),
|
||||
{
|
||||
account_number: params.counterAccount,
|
||||
debit_amount: 0,
|
||||
credit_amount: totalAmount,
|
||||
line_description: 'Milersättning att utbetala',
|
||||
},
|
||||
],
|
||||
})
|
||||
} catch (err) {
|
||||
await revertClaim()
|
||||
throw err
|
||||
}
|
||||
|
||||
const { data: linked, error: linkError } = await supabase
|
||||
.from('mileage_trips')
|
||||
.update({ journal_entry_id: entry.id })
|
||||
.eq('company_id', companyId)
|
||||
.eq('status', 'booked')
|
||||
.in('id', claimedIds)
|
||||
.select('id')
|
||||
|
||||
if (linkError || !linked || linked.length !== claimedIds.length) {
|
||||
// The verifikat exists and the trips are booked, but some rows lost the
|
||||
// entry link. Surface loudly so the körjournal can be repaired.
|
||||
return { ok: false, code: 'STAMP_FAILED', journalEntryId: entry.id }
|
||||
}
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
journalEntryId: entry.id,
|
||||
voucherNumber: entry.voucher_number ?? null,
|
||||
voucherSeries: entry.voucher_series ?? null,
|
||||
tripCount: trips.length,
|
||||
totalAmount,
|
||||
summaries,
|
||||
}
|
||||
}
|
||||
|
||||
export type PushToSalaryRunResult =
|
||||
| { ok: true; tripCount: number; totalAmount: number; summaries: MileagePeriodSummary[] }
|
||||
| {
|
||||
ok: false
|
||||
code:
|
||||
| 'NO_TRIPS'
|
||||
| 'RUN_NOT_FOUND'
|
||||
| 'RUN_NOT_EDITABLE'
|
||||
| 'EMPLOYEE_NOT_IN_RUN'
|
||||
| 'CLAIM_LOST'
|
||||
}
|
||||
|
||||
/**
|
||||
* Push the period's draft trips into a draft/review salary run as
|
||||
* mileage_taxfree line items (kostnadsersättning: not taxable, no avgifter,
|
||||
* not semesterlönegrundande). The salary run's own booking flow then carries
|
||||
* the amounts into the verifikat and AGI.
|
||||
*/
|
||||
export async function pushMileageToSalaryRun(
|
||||
supabase: SupabaseClient,
|
||||
companyId: string,
|
||||
params: {
|
||||
runId: string
|
||||
employeeId: string
|
||||
from: string
|
||||
to: string
|
||||
includeUnassigned?: boolean
|
||||
}
|
||||
): Promise<PushToSalaryRunResult> {
|
||||
const { data: run } = await supabase
|
||||
.from('salary_runs')
|
||||
.select('id, status')
|
||||
.eq('id', params.runId)
|
||||
.eq('company_id', companyId)
|
||||
.single()
|
||||
if (!run) return { ok: false, code: 'RUN_NOT_FOUND' }
|
||||
if (run.status !== 'draft' && run.status !== 'review') {
|
||||
return { ok: false, code: 'RUN_NOT_EDITABLE' }
|
||||
}
|
||||
|
||||
const { data: sre } = await supabase
|
||||
.from('salary_run_employees')
|
||||
.select('id')
|
||||
.eq('salary_run_id', params.runId)
|
||||
.eq('employee_id', params.employeeId)
|
||||
.eq('company_id', companyId)
|
||||
.single()
|
||||
if (!sre) return { ok: false, code: 'EMPLOYEE_NOT_IN_RUN' }
|
||||
|
||||
const all = await listTrips(supabase, companyId, {
|
||||
from: params.from,
|
||||
to: params.to,
|
||||
status: 'draft',
|
||||
})
|
||||
const includeUnassigned = params.includeUnassigned ?? true
|
||||
const trips = all.filter(
|
||||
(t) =>
|
||||
t.employee_id === params.employeeId || (includeUnassigned && t.employee_id === null)
|
||||
)
|
||||
if (trips.length === 0) return { ok: false, code: 'NO_TRIPS' }
|
||||
|
||||
const config = await loadPayrollConfig(supabase, Number(params.to.slice(0, 4)))
|
||||
const summaries = summarizeTrips(trips, config)
|
||||
const totalAmount = round2(summaries.reduce((sum, s) => sum + s.amount, 0))
|
||||
|
||||
// Claim the trips BEFORE inserting the salary lines: a retry after a
|
||||
// partial failure would otherwise insert the mileage_taxfree items twice
|
||||
// for the same trips (double pay). A lost claim reverts and reports.
|
||||
const tripIds = trips.map((t) => t.id)
|
||||
const { data: claimed, error: claimError } = await supabase
|
||||
.from('mileage_trips')
|
||||
.update({ status: 'booked', salary_run_id: params.runId })
|
||||
.eq('company_id', companyId)
|
||||
.eq('status', 'draft')
|
||||
.in('id', tripIds)
|
||||
.select('id')
|
||||
if (claimError) {
|
||||
throw new Error(`Failed to claim mileage trips: ${claimError.message}`)
|
||||
}
|
||||
const claimedIds = (claimed ?? []).map((row) => row.id as string)
|
||||
const revertClaim = async () => {
|
||||
if (claimedIds.length === 0) return
|
||||
await supabase
|
||||
.from('mileage_trips')
|
||||
.update({ status: 'draft', salary_run_id: null })
|
||||
.eq('company_id', companyId)
|
||||
.eq('status', 'booked')
|
||||
.is('journal_entry_id', null)
|
||||
.in('id', claimedIds)
|
||||
}
|
||||
if (claimedIds.length !== tripIds.length) {
|
||||
await revertClaim()
|
||||
return { ok: false, code: 'CLAIM_LOST' }
|
||||
}
|
||||
|
||||
const { error: itemError } = await supabase.from('salary_line_items').insert(
|
||||
summaries.map((s, index) => ({
|
||||
salary_run_employee_id: sre.id,
|
||||
company_id: companyId,
|
||||
item_type: 'mileage_taxfree',
|
||||
description: `Milersättning ${VEHICLE_TYPE_LABELS[s.vehicle_type]} ${params.from} till ${params.to} (${s.trip_count} resor)`,
|
||||
quantity: s.total_mil,
|
||||
unit_price: s.rate_per_mil,
|
||||
amount: s.amount,
|
||||
is_taxable: false,
|
||||
is_avgift_basis: false,
|
||||
is_vacation_basis: false,
|
||||
account_number: MILEAGE_TAXFREE_ACCOUNT,
|
||||
sort_order: 100 + index,
|
||||
}))
|
||||
)
|
||||
if (itemError) {
|
||||
await revertClaim()
|
||||
throw new Error(`Failed to add mileage line items: ${itemError.message}`)
|
||||
}
|
||||
|
||||
return { ok: true, tripCount: trips.length, totalAmount, summaries }
|
||||
}
|
||||
@@ -4661,6 +4661,106 @@ async function commitCreateSalaryRun(
|
||||
}
|
||||
}
|
||||
|
||||
async function commitLogMileageTrip(
|
||||
supabase: SupabaseClient,
|
||||
userId: string,
|
||||
companyId: string,
|
||||
params: Record<string, unknown>
|
||||
): Promise<ExecutorResult> {
|
||||
try {
|
||||
const { createTrip } = await import('@/lib/mileage/mileage-service')
|
||||
const trip = await createTrip(supabase, companyId, userId, {
|
||||
trip_date: params.trip_date as string,
|
||||
vehicle_type: params.vehicle_type as never,
|
||||
vehicle_registration: (params.vehicle_registration as string) || null,
|
||||
odometer_start: (params.odometer_start as number) ?? null,
|
||||
odometer_end: (params.odometer_end as number) ?? null,
|
||||
distance_km: params.distance_km as number,
|
||||
from_location: params.from_location as string,
|
||||
to_location: params.to_location as string,
|
||||
purpose: params.purpose as string,
|
||||
visited: (params.visited as string) || null,
|
||||
is_round_trip: params.is_round_trip === true,
|
||||
employee_id: (params.employee_id as string) || null,
|
||||
notes: (params.notes as string) || null,
|
||||
created_via: 'mcp',
|
||||
})
|
||||
return {
|
||||
data: {
|
||||
mileage_trip_id: trip.id,
|
||||
trip_date: trip.trip_date,
|
||||
distance_km: trip.distance_km,
|
||||
status: trip.status,
|
||||
},
|
||||
}
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : 'Failed to log mileage trip'
|
||||
// Input-validation failures from the service are permanent for these
|
||||
// params: 400 so agents fix the arguments instead of retrying blindly.
|
||||
const isValidation = /registreringsnummer|hittades inte/i.test(message)
|
||||
return { error: message, status: isValidation ? 400 : 500 }
|
||||
}
|
||||
}
|
||||
|
||||
async function commitBookMileagePeriod(
|
||||
supabase: SupabaseClient,
|
||||
userId: string,
|
||||
companyId: string,
|
||||
params: Record<string, unknown>
|
||||
): Promise<ExecutorResult> {
|
||||
try {
|
||||
const { bookMileagePeriod } = await import('@/lib/mileage/mileage-service')
|
||||
const result = await bookMileagePeriod(supabase, companyId, userId, {
|
||||
from: params.from as string,
|
||||
to: params.to as string,
|
||||
entryDate: params.entry_date as string,
|
||||
counterAccount: (params.counter_account as never) || '2820',
|
||||
employeeId: (params.employee_id as string) || undefined,
|
||||
createdVia: 'mcp',
|
||||
// Staged approvals freeze the trip set: what was previewed is exactly
|
||||
// what may be booked; drift fails the commit instead of booking blind.
|
||||
expectedTripIds: Array.isArray(params.trip_ids)
|
||||
? (params.trip_ids as string[])
|
||||
: undefined,
|
||||
})
|
||||
if (!result.ok) {
|
||||
if (result.code === 'NO_TRIPS') {
|
||||
return { error: 'No unbooked trips in the selected period', status: 400 }
|
||||
}
|
||||
if (result.code === 'MIXED_EMPLOYEES') {
|
||||
return { error: 'The period spans several employees; book per employee via employee_id', status: 400 }
|
||||
}
|
||||
if (result.code === 'PERIOD_NOT_OPEN') {
|
||||
return { error: 'The entry date falls in a closed or locked period', status: 400 }
|
||||
}
|
||||
if (result.code === 'TRIPS_CHANGED' || result.code === 'CLAIM_LOST') {
|
||||
return {
|
||||
error: 'The körjournal changed since this booking was staged; stage it again to get a fresh preview',
|
||||
status: 409,
|
||||
}
|
||||
}
|
||||
return {
|
||||
error: `Voucher ${result.journalEntryId} was created but trips could not all be linked; review the körjournal before booking again`,
|
||||
status: 500,
|
||||
}
|
||||
}
|
||||
return {
|
||||
data: {
|
||||
journal_entry_id: result.journalEntryId,
|
||||
voucher: `${result.voucherSeries ?? ''}${result.voucherNumber ?? ''}`,
|
||||
trip_count: result.tripCount,
|
||||
total_amount: result.totalAmount,
|
||||
summaries: result.summaries,
|
||||
},
|
||||
}
|
||||
} catch (err) {
|
||||
return {
|
||||
error: err instanceof Error ? err.message : 'Failed to book mileage period',
|
||||
status: 500,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function commitGenerateAgi(
|
||||
supabase: SupabaseClient,
|
||||
userId: string,
|
||||
@@ -5572,6 +5672,12 @@ async function commitPendingOperationInner(
|
||||
case 'create_salary_run':
|
||||
result = await commitCreateSalaryRun(supabase, userId, companyId, pendingOp.params)
|
||||
break
|
||||
case 'log_mileage_trip':
|
||||
result = await commitLogMileageTrip(supabase, userId, companyId, pendingOp.params)
|
||||
break
|
||||
case 'book_mileage_period':
|
||||
result = await commitBookMileagePeriod(supabase, userId, companyId, pendingOp.params)
|
||||
break
|
||||
case 'generate_agi':
|
||||
result = await commitGenerateAgi(supabase, userId, companyId, pendingOp.params)
|
||||
break
|
||||
|
||||
@@ -198,6 +198,16 @@ export const OPERATION_RISK_TIERS: Record<string, RiskLevel> = {
|
||||
// both attach an existing booking to a different entity.
|
||||
link_transaction_journal_entry: 'medium',
|
||||
|
||||
// ── Körjournal (mileage) ───────────────────────────────────────────
|
||||
// A trip row is pure travel documentation: no booking impact until a
|
||||
// separate book operation. Same tier as create_customer.
|
||||
log_mileage_trip: 'low',
|
||||
// Books one verifikat with fixed lines derived from logged trips (7331 +
|
||||
// whitelisted counter account) at the DB-configured schablon rate: not the
|
||||
// arbitrary-line surface that makes create_voucher 'high'. Reversible via
|
||||
// storno: same tier as post_annual_depreciation.
|
||||
book_mileage_period: 'medium',
|
||||
|
||||
// ── Skatteverket filing (PR5) ──────────────────────────────────────
|
||||
// External + irreversible once signed. Commit sends the declaration for
|
||||
// BankID signing; the user's signature in the browser is the filing act.
|
||||
|
||||
@@ -912,6 +912,9 @@ export const MASTER_DATA_DUMP_TABLES: MasterDataTableSpec[] = [
|
||||
{ name: 'salary_payslip_links', file: 'salary_payslip_links.json' },
|
||||
{ name: 'shift_premium_rules', file: 'shift_premium_rules.json' },
|
||||
{ name: 'agi_declarations', file: 'agi_declarations.json', orderBy: 'created_at' },
|
||||
// Körjournal: trip log underlag for milersättning verifikat (BFL 7-year
|
||||
// retention per Skatteverket's körjournal documentation requirement).
|
||||
{ name: 'mileage_trips', file: 'mileage_trips.json', orderBy: 'trip_date' },
|
||||
// Assets and accruals
|
||||
{ name: 'assets', file: 'assets.json', orderBy: 'created_at' },
|
||||
{ name: 'depreciation_schedules', file: 'depreciation_schedules.json', orderBy: 'created_at' },
|
||||
|
||||
@@ -97,6 +97,7 @@
|
||||
"insights": "Insights",
|
||||
"import": "Import/Export",
|
||||
"salary": "Payroll",
|
||||
"mileage": "Driving log",
|
||||
"employees": "Employees",
|
||||
"time_tracking": "Time tracking",
|
||||
"expenses": "Expenses",
|
||||
@@ -6570,5 +6571,68 @@
|
||||
"payment_bankgiro_label": "Bankgiro (Skatteverket)",
|
||||
"payment_shortfall_label": "Missing on the account",
|
||||
"pgnote": "Synced automatically every night. Completed events are booked against 1630 Skattekonto, usually automatically, and deviations are flagged here. Kronofogden: {amount}."
|
||||
},
|
||||
"mileage": {
|
||||
"title": "Driving log",
|
||||
"new_trip": "New trip",
|
||||
"edit_trip": "Edit trip",
|
||||
"copy_trip": "Copy trip",
|
||||
"delete_trip": "Delete trip",
|
||||
"all_months": "All months",
|
||||
"month_filter": "Filter by month",
|
||||
"draft_summary": "{count, plural, =1 {1 unbooked trip} other {# unbooked trips}} · {km} km",
|
||||
"book_period": "Book mileage allowance",
|
||||
"export_csv": "Export CSV",
|
||||
"empty_title": "No trips yet",
|
||||
"empty_description": "Log business trips with date, distance and purpose. The mileage allowance is then booked tax-free at the statutory rate (25 kr per 10 km for a private car).",
|
||||
"col_date": "Date",
|
||||
"col_route": "Trip",
|
||||
"col_purpose": "Purpose",
|
||||
"col_km": "Km",
|
||||
"col_status": "Status",
|
||||
"status_draft": "Draft",
|
||||
"status_booked": "Booked",
|
||||
"round_trip_suffix": "(round trip)",
|
||||
"field_date": "Date",
|
||||
"field_vehicle": "Vehicle",
|
||||
"vehicle_own_car": "Private car",
|
||||
"vehicle_company_fossil": "Company car (petrol/diesel)",
|
||||
"vehicle_company_electric": "Company car (electric)",
|
||||
"field_from": "From",
|
||||
"field_to": "To",
|
||||
"field_purpose": "Purpose",
|
||||
"purpose_placeholder": "E.g. client visit, material purchase",
|
||||
"field_km": "Distance (km, one way)",
|
||||
"field_km_total": "Distance (km total)",
|
||||
"field_round_trip": "Round trip",
|
||||
"more_fields": "More fields",
|
||||
"field_regnr": "Reg. no.",
|
||||
"field_odometer_start": "Odometer at start",
|
||||
"field_odometer_end": "Odometer at arrival",
|
||||
"field_visited": "Visited (client/site)",
|
||||
"field_notes": "Note",
|
||||
"cancel": "Cancel",
|
||||
"save_trip": "Save trip",
|
||||
"saving": "Saving...",
|
||||
"trip_saved": "Trip saved",
|
||||
"trip_updated": "Trip updated",
|
||||
"trip_deleted": "Trip deleted",
|
||||
"form_incomplete": "Fill in date, distance, from, to and purpose",
|
||||
"save_error": "The trip could not be saved",
|
||||
"load_error": "The driving log could not be loaded",
|
||||
"delete_error": "The trip could not be deleted",
|
||||
"field_period_from": "Period from",
|
||||
"field_period_to": "Period to",
|
||||
"field_entry_date": "Entry date",
|
||||
"field_counter_account": "Counter account",
|
||||
"counter_2820": "2820 – Liability to employees",
|
||||
"counter_2893": "2893 – Shareholder account",
|
||||
"counter_1930": "1930 – Already paid out from bank",
|
||||
"book_explainer": "All unbooked trips in the period are booked as one voucher: mileage allowance on account 7331 at the tax-free statutory rate, against the selected counter account.",
|
||||
"confirm_book": "Book",
|
||||
"booking": "Booking...",
|
||||
"book_error": "The mileage allowance could not be booked",
|
||||
"booked_title": "Voucher {voucher} booked",
|
||||
"booked_description": "{count} trips, total {amount}"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -97,6 +97,7 @@
|
||||
"insights": "Insikter",
|
||||
"import": "Importera/Exportera",
|
||||
"salary": "Löner",
|
||||
"mileage": "Körjournal",
|
||||
"employees": "Anställda",
|
||||
"time_tracking": "Tidrapportering",
|
||||
"expenses": "Utlägg",
|
||||
@@ -6570,5 +6571,68 @@
|
||||
"payment_bankgiro_label": "Bankgiro (Skatteverket)",
|
||||
"payment_shortfall_label": "Saknas på kontot",
|
||||
"pgnote": "Synkas automatiskt varje natt. Genomförda händelser bokförs mot 1630 Skattekonto, oftast automatiskt, och avvikelser flaggas här. Kronofogden: {amount}."
|
||||
},
|
||||
"mileage": {
|
||||
"title": "Körjournal",
|
||||
"new_trip": "Ny resa",
|
||||
"edit_trip": "Ändra resa",
|
||||
"copy_trip": "Kopiera resa",
|
||||
"delete_trip": "Ta bort resa",
|
||||
"all_months": "Alla månader",
|
||||
"month_filter": "Filtrera på månad",
|
||||
"draft_summary": "{count, plural, =1 {1 obokförd resa} other {# obokförda resor}} · {km} km",
|
||||
"book_period": "Bokför milersättning",
|
||||
"export_csv": "Exportera CSV",
|
||||
"empty_title": "Inga resor ännu",
|
||||
"empty_description": "Logga tjänsteresor med datum, sträcka och ärende. Milersättningen bokförs sedan skattefritt enligt schablon (25 kr/mil för egen bil).",
|
||||
"col_date": "Datum",
|
||||
"col_route": "Resa",
|
||||
"col_purpose": "Ärende",
|
||||
"col_km": "Km",
|
||||
"col_status": "Status",
|
||||
"status_draft": "Utkast",
|
||||
"status_booked": "Bokförd",
|
||||
"round_trip_suffix": "(tur och retur)",
|
||||
"field_date": "Datum",
|
||||
"field_vehicle": "Fordon",
|
||||
"vehicle_own_car": "Egen bil",
|
||||
"vehicle_company_fossil": "Förmånsbil (bensin/diesel)",
|
||||
"vehicle_company_electric": "Förmånsbil (el)",
|
||||
"field_from": "Från",
|
||||
"field_to": "Till",
|
||||
"field_purpose": "Ärende",
|
||||
"purpose_placeholder": "T.ex. kundbesök, materialinköp",
|
||||
"field_km": "Sträcka (km, enkel väg)",
|
||||
"field_km_total": "Sträcka (km totalt)",
|
||||
"field_round_trip": "Tur och retur",
|
||||
"more_fields": "Fler fält",
|
||||
"field_regnr": "Regnr",
|
||||
"field_odometer_start": "Mätarställning start",
|
||||
"field_odometer_end": "Mätarställning slut",
|
||||
"field_visited": "Besökt (kund/plats)",
|
||||
"field_notes": "Anteckning",
|
||||
"cancel": "Avbryt",
|
||||
"save_trip": "Spara resa",
|
||||
"saving": "Sparar...",
|
||||
"trip_saved": "Resan sparades",
|
||||
"trip_updated": "Resan uppdaterades",
|
||||
"trip_deleted": "Resan togs bort",
|
||||
"form_incomplete": "Fyll i datum, sträcka, från, till och ärende",
|
||||
"save_error": "Resan kunde inte sparas",
|
||||
"load_error": "Körjournalen kunde inte hämtas",
|
||||
"delete_error": "Resan kunde inte tas bort",
|
||||
"field_period_from": "Period från",
|
||||
"field_period_to": "Period till",
|
||||
"field_entry_date": "Bokföringsdatum",
|
||||
"field_counter_account": "Motkonto",
|
||||
"counter_2820": "2820 – Skuld till anställda",
|
||||
"counter_2893": "2893 – Avräkning aktieägare",
|
||||
"counter_1930": "1930 – Redan utbetald från bank",
|
||||
"book_explainer": "Alla obokförda resor i perioden bokförs som ett verifikat: milersättning på konto 7331 enligt skattefri schablon, med valt motkonto.",
|
||||
"confirm_book": "Bokför",
|
||||
"booking": "Bokför...",
|
||||
"book_error": "Milersättningen kunde inte bokföras",
|
||||
"booked_title": "Verifikat {voucher} bokfört",
|
||||
"booked_description": "{count} resor, totalt {amount}"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
-- Körjournal: mileage trip log per Skatteverket documentation requirements.
|
||||
-- Trips are drafts until booked; a booked trip is underlag for a verifikat
|
||||
-- (or a salary run line) and falls under BFL 7-year retention, so booked
|
||||
-- rows can never be deleted.
|
||||
|
||||
CREATE TABLE public.mileage_trips (
|
||||
id uuid DEFAULT gen_random_uuid() PRIMARY KEY,
|
||||
company_id uuid NOT NULL REFERENCES public.companies(id) ON DELETE CASCADE,
|
||||
user_id uuid NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE,
|
||||
employee_id uuid REFERENCES public.employees(id) ON DELETE SET NULL,
|
||||
trip_date date NOT NULL,
|
||||
vehicle_type text NOT NULL DEFAULT 'own_car'
|
||||
CHECK (vehicle_type IN ('own_car', 'company_car_fossil', 'company_car_electric')),
|
||||
vehicle_registration text,
|
||||
odometer_start integer CHECK (odometer_start >= 0),
|
||||
odometer_end integer CHECK (odometer_end >= 0),
|
||||
distance_km numeric(10,1) NOT NULL CHECK (distance_km > 0),
|
||||
from_location text NOT NULL,
|
||||
to_location text NOT NULL,
|
||||
purpose text NOT NULL,
|
||||
visited text,
|
||||
is_round_trip boolean NOT NULL DEFAULT false,
|
||||
status text NOT NULL DEFAULT 'draft' CHECK (status IN ('draft', 'booked')),
|
||||
journal_entry_id uuid REFERENCES public.journal_entries(id) ON DELETE SET NULL,
|
||||
salary_run_id uuid REFERENCES public.salary_runs(id) ON DELETE SET NULL,
|
||||
notes text,
|
||||
created_via text NOT NULL DEFAULT 'manual' CHECK (created_via IN ('manual', 'mcp', 'import')),
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now(),
|
||||
CONSTRAINT mileage_trips_odometer_order
|
||||
CHECK (odometer_start IS NULL OR odometer_end IS NULL OR odometer_end > odometer_start)
|
||||
);
|
||||
|
||||
ALTER TABLE public.mileage_trips ENABLE ROW LEVEL SECURITY;
|
||||
|
||||
CREATE POLICY "view own-company mileage_trips"
|
||||
ON public.mileage_trips FOR SELECT USING (company_id IN (SELECT user_company_ids()));
|
||||
CREATE POLICY "insert own-company mileage_trips"
|
||||
ON public.mileage_trips FOR INSERT WITH CHECK (company_id IN (SELECT user_company_ids()));
|
||||
CREATE POLICY "update own-company mileage_trips"
|
||||
ON public.mileage_trips FOR UPDATE USING (company_id IN (SELECT user_company_ids()));
|
||||
CREATE POLICY "delete own-company mileage_trips"
|
||||
ON public.mileage_trips FOR DELETE USING (company_id IN (SELECT user_company_ids()));
|
||||
|
||||
CREATE INDEX idx_mileage_trips_company_date ON public.mileage_trips (company_id, trip_date DESC);
|
||||
CREATE INDEX idx_mileage_trips_company_status ON public.mileage_trips (company_id, status);
|
||||
CREATE INDEX idx_mileage_trips_journal_entry
|
||||
ON public.mileage_trips (journal_entry_id) WHERE journal_entry_id IS NOT NULL;
|
||||
|
||||
CREATE TRIGGER set_updated_at_mileage_trips
|
||||
BEFORE UPDATE ON public.mileage_trips
|
||||
FOR EACH ROW EXECUTE FUNCTION public.update_updated_at_column();
|
||||
|
||||
CREATE TRIGGER audit_mileage_trips
|
||||
AFTER INSERT OR UPDATE OR DELETE ON public.mileage_trips
|
||||
FOR EACH ROW EXECUTE FUNCTION public.write_audit_log();
|
||||
|
||||
-- A booked trip is bookkeeping underlag (BFL 7 kap): block deletion.
|
||||
CREATE OR REPLACE FUNCTION public.block_booked_mileage_trip_deletion()
|
||||
RETURNS trigger
|
||||
LANGUAGE plpgsql
|
||||
SECURITY DEFINER
|
||||
SET search_path = public
|
||||
AS $$
|
||||
BEGIN
|
||||
IF OLD.status = 'booked' THEN
|
||||
RAISE EXCEPTION 'Cannot delete a booked mileage trip: it is retained as underlag (BFL). Reverse the verifikat first.'
|
||||
USING ERRCODE = 'P0001';
|
||||
END IF;
|
||||
RETURN OLD;
|
||||
END;
|
||||
$$;
|
||||
|
||||
CREATE TRIGGER block_booked_mileage_trip_deletion
|
||||
BEFORE DELETE ON public.mileage_trips
|
||||
FOR EACH ROW EXECUTE FUNCTION public.block_booked_mileage_trip_deletion();
|
||||
|
||||
NOTIFY pgrst, 'reload schema';
|
||||
@@ -0,0 +1,77 @@
|
||||
-- Add log_mileage_trip and book_mileage_period to the pending_operations
|
||||
-- operation type CHECK (korjournal MCP tools, PR #1448).
|
||||
--
|
||||
-- NOTE on the value list: this constraint is re-created wholesale, so the list
|
||||
-- below is every value from 20260727110000 PLUS the two new mileage values.
|
||||
|
||||
ALTER TABLE public.pending_operations
|
||||
DROP CONSTRAINT IF EXISTS pending_operations_operation_type_check;
|
||||
|
||||
ALTER TABLE public.pending_operations
|
||||
ADD CONSTRAINT pending_operations_operation_type_check
|
||||
CHECK (operation_type IN (
|
||||
'categorize_transaction',
|
||||
'create_customer',
|
||||
'create_invoice',
|
||||
'mark_invoice_paid',
|
||||
'send_invoice',
|
||||
'mark_invoice_sent',
|
||||
'match_transaction_invoice',
|
||||
'close_period',
|
||||
'lock_period',
|
||||
'unlock_period',
|
||||
'set_opening_balances',
|
||||
'run_year_end',
|
||||
'run_currency_revaluation',
|
||||
'import_sie',
|
||||
'explain_voucher_gap',
|
||||
'uncategorize_transaction',
|
||||
'approve_supplier_invoice',
|
||||
'credit_supplier_invoice',
|
||||
'credit_invoice',
|
||||
'convert_invoice',
|
||||
'create_transaction',
|
||||
'attach_document_to_transaction',
|
||||
'create_voucher',
|
||||
'correct_entry',
|
||||
'reverse_entry',
|
||||
'create_supplier',
|
||||
'create_supplier_invoice_from_inbox',
|
||||
'post_annual_depreciation',
|
||||
'link_invoice_voucher',
|
||||
'undo_sie_import',
|
||||
'match_batch_allocate',
|
||||
'bulk_book_transactions',
|
||||
'create_salary_run',
|
||||
'generate_agi',
|
||||
'link_transaction_journal_entry',
|
||||
'link_supplier_invoice_voucher',
|
||||
'submit_vat_declaration',
|
||||
'submit_agi',
|
||||
'create_article',
|
||||
'update_article',
|
||||
'bulk_book_inbox_items',
|
||||
'create_dimension_value',
|
||||
'retag_line_dimensions',
|
||||
'link_document_to_voucher',
|
||||
'update_payslip_line',
|
||||
'register_absence',
|
||||
'create_employee',
|
||||
'update_employee',
|
||||
'set_employee_opening_balances',
|
||||
'vacation_year_close',
|
||||
'create_account',
|
||||
'update_account',
|
||||
'set_voucher_note',
|
||||
'book_salary_run',
|
||||
'delete_absence',
|
||||
'update_company_settings',
|
||||
'update_customer',
|
||||
'update_invoice',
|
||||
'create_recurring_schedule',
|
||||
'update_recurring_schedule',
|
||||
'log_mileage_trip',
|
||||
'book_mileage_period'
|
||||
)) NOT VALID;
|
||||
|
||||
NOTIFY pgrst, 'reload schema';
|
||||
@@ -0,0 +1,5 @@
|
||||
-- Validate the operation type CHECK re-added in 20260807093856.
|
||||
-- Separate transaction to avoid a full-table scan under the stronger lock.
|
||||
|
||||
ALTER TABLE public.pending_operations
|
||||
VALIDATE CONSTRAINT pending_operations_operation_type_check;
|
||||
@@ -0,0 +1,75 @@
|
||||
-- Booked mileage trips are korjournal underlag for a posted verifikat:
|
||||
-- immutable at the database layer (BFL 5 kap 5 §, 7 kap), mirroring the
|
||||
-- delete block from 20260807084705. Allowed transitions only:
|
||||
-- * draft -> booked (the booking service's claim; may set salary_run_id)
|
||||
-- * booked -> draft revert of an UNLINKED claim (journal_entry_id IS NULL),
|
||||
-- clearing salary_run_id
|
||||
-- * booked -> booked filling journal_entry_id / salary_run_id from NULL
|
||||
-- * notes may always change (annotation, mirrors the verifikat-notes
|
||||
-- carve-out); everything else on a booked row is frozen.
|
||||
|
||||
CREATE OR REPLACE FUNCTION public.enforce_booked_mileage_trip_immutability()
|
||||
RETURNS trigger
|
||||
LANGUAGE plpgsql
|
||||
SECURITY DEFINER
|
||||
SET search_path = public
|
||||
AS $$
|
||||
DECLARE
|
||||
core_changed boolean;
|
||||
BEGIN
|
||||
IF OLD.status <> 'booked' THEN
|
||||
RETURN NEW;
|
||||
END IF;
|
||||
|
||||
core_changed :=
|
||||
NEW.trip_date IS DISTINCT FROM OLD.trip_date
|
||||
OR NEW.vehicle_type IS DISTINCT FROM OLD.vehicle_type
|
||||
OR NEW.vehicle_registration IS DISTINCT FROM OLD.vehicle_registration
|
||||
OR NEW.odometer_start IS DISTINCT FROM OLD.odometer_start
|
||||
OR NEW.odometer_end IS DISTINCT FROM OLD.odometer_end
|
||||
OR NEW.distance_km IS DISTINCT FROM OLD.distance_km
|
||||
OR NEW.from_location IS DISTINCT FROM OLD.from_location
|
||||
OR NEW.to_location IS DISTINCT FROM OLD.to_location
|
||||
OR NEW.purpose IS DISTINCT FROM OLD.purpose
|
||||
OR NEW.visited IS DISTINCT FROM OLD.visited
|
||||
OR NEW.is_round_trip IS DISTINCT FROM OLD.is_round_trip
|
||||
OR NEW.employee_id IS DISTINCT FROM OLD.employee_id
|
||||
OR NEW.company_id IS DISTINCT FROM OLD.company_id
|
||||
OR NEW.user_id IS DISTINCT FROM OLD.user_id
|
||||
OR NEW.created_via IS DISTINCT FROM OLD.created_via;
|
||||
|
||||
IF core_changed THEN
|
||||
RAISE EXCEPTION 'Cannot modify a booked mileage trip: it is retained as underlag (BFL). Reverse the verifikat first.'
|
||||
USING ERRCODE = 'P0001';
|
||||
END IF;
|
||||
|
||||
-- Revert of an unlinked claim back to draft.
|
||||
IF NEW.status = 'draft' THEN
|
||||
IF OLD.journal_entry_id IS NOT NULL THEN
|
||||
RAISE EXCEPTION 'Cannot unbook a mileage trip linked to a verifikat. Reverse the verifikat first.'
|
||||
USING ERRCODE = 'P0001';
|
||||
END IF;
|
||||
RETURN NEW;
|
||||
END IF;
|
||||
|
||||
-- Booked stays booked: links may only be set from NULL, never rewritten.
|
||||
IF OLD.journal_entry_id IS NOT NULL
|
||||
AND NEW.journal_entry_id IS DISTINCT FROM OLD.journal_entry_id THEN
|
||||
RAISE EXCEPTION 'Cannot repoint a booked mileage trip to another verifikat.'
|
||||
USING ERRCODE = 'P0001';
|
||||
END IF;
|
||||
IF OLD.salary_run_id IS NOT NULL
|
||||
AND NEW.salary_run_id IS DISTINCT FROM OLD.salary_run_id THEN
|
||||
RAISE EXCEPTION 'Cannot repoint a booked mileage trip to another salary run.'
|
||||
USING ERRCODE = 'P0001';
|
||||
END IF;
|
||||
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$;
|
||||
|
||||
CREATE TRIGGER enforce_booked_mileage_trip_immutability
|
||||
BEFORE UPDATE ON public.mileage_trips
|
||||
FOR EACH ROW EXECUTE FUNCTION public.enforce_booked_mileage_trip_immutability();
|
||||
|
||||
NOTIFY pgrst, 'reload schema';
|
||||
@@ -0,0 +1,71 @@
|
||||
-- Tighten the booked -> draft revert rule from 20260807113215: a revert must
|
||||
-- also leave the trip detached from any salary run. Without this, a booked
|
||||
-- trip claimed by a salary run (journal_entry_id NULL, salary_run_id set)
|
||||
-- could revert to draft while keeping its salary_run_id: a draft trip whose
|
||||
-- allowance already sits in a run is re-bookable, i.e. double pay.
|
||||
|
||||
CREATE OR REPLACE FUNCTION public.enforce_booked_mileage_trip_immutability()
|
||||
RETURNS trigger
|
||||
LANGUAGE plpgsql
|
||||
SECURITY DEFINER
|
||||
SET search_path = public
|
||||
AS $$
|
||||
DECLARE
|
||||
core_changed boolean;
|
||||
BEGIN
|
||||
IF OLD.status <> 'booked' THEN
|
||||
RETURN NEW;
|
||||
END IF;
|
||||
|
||||
core_changed :=
|
||||
NEW.trip_date IS DISTINCT FROM OLD.trip_date
|
||||
OR NEW.vehicle_type IS DISTINCT FROM OLD.vehicle_type
|
||||
OR NEW.vehicle_registration IS DISTINCT FROM OLD.vehicle_registration
|
||||
OR NEW.odometer_start IS DISTINCT FROM OLD.odometer_start
|
||||
OR NEW.odometer_end IS DISTINCT FROM OLD.odometer_end
|
||||
OR NEW.distance_km IS DISTINCT FROM OLD.distance_km
|
||||
OR NEW.from_location IS DISTINCT FROM OLD.from_location
|
||||
OR NEW.to_location IS DISTINCT FROM OLD.to_location
|
||||
OR NEW.purpose IS DISTINCT FROM OLD.purpose
|
||||
OR NEW.visited IS DISTINCT FROM OLD.visited
|
||||
OR NEW.is_round_trip IS DISTINCT FROM OLD.is_round_trip
|
||||
OR NEW.employee_id IS DISTINCT FROM OLD.employee_id
|
||||
OR NEW.company_id IS DISTINCT FROM OLD.company_id
|
||||
OR NEW.user_id IS DISTINCT FROM OLD.user_id
|
||||
OR NEW.created_via IS DISTINCT FROM OLD.created_via;
|
||||
|
||||
IF core_changed THEN
|
||||
RAISE EXCEPTION 'Cannot modify a booked mileage trip: it is retained as underlag (BFL). Reverse the verifikat first.'
|
||||
USING ERRCODE = 'P0001';
|
||||
END IF;
|
||||
|
||||
-- Revert of an unlinked claim back to draft: must detach from any salary
|
||||
-- run in the same statement, so a draft trip can never keep pointing at a
|
||||
-- run that already carries its allowance.
|
||||
IF NEW.status = 'draft' THEN
|
||||
IF OLD.journal_entry_id IS NOT NULL THEN
|
||||
RAISE EXCEPTION 'Cannot unbook a mileage trip linked to a verifikat. Reverse the verifikat first.'
|
||||
USING ERRCODE = 'P0001';
|
||||
END IF;
|
||||
IF NEW.salary_run_id IS NOT NULL THEN
|
||||
RAISE EXCEPTION 'Reverting a mileage trip to draft must clear salary_run_id.'
|
||||
USING ERRCODE = 'P0001';
|
||||
END IF;
|
||||
RETURN NEW;
|
||||
END IF;
|
||||
|
||||
-- Booked stays booked: links may only be set from NULL, never rewritten.
|
||||
IF OLD.journal_entry_id IS NOT NULL
|
||||
AND NEW.journal_entry_id IS DISTINCT FROM OLD.journal_entry_id THEN
|
||||
RAISE EXCEPTION 'Cannot repoint a booked mileage trip to another verifikat.'
|
||||
USING ERRCODE = 'P0001';
|
||||
END IF;
|
||||
IF OLD.salary_run_id IS NOT NULL
|
||||
AND NEW.salary_run_id IS DISTINCT FROM OLD.salary_run_id THEN
|
||||
RAISE EXCEPTION 'Cannot repoint a booked mileage trip to another salary run.'
|
||||
USING ERRCODE = 'P0001';
|
||||
END IF;
|
||||
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$;
|
||||
@@ -0,0 +1,173 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { insertPostedJournalEntry, seedCompany } from '@/tests/pg/fixtures'
|
||||
import { getPool, withUserContext } from '@/tests/pg/setup'
|
||||
|
||||
/**
|
||||
* Locks in the mileage_trips migration (20260807084705):
|
||||
*
|
||||
* - RLS scopes rows to the user's companies via user_company_ids();
|
||||
* - a booked trip can never be deleted (körjournal is underlag, BFL 7-year
|
||||
* retention: block_booked_mileage_trip_deletion trigger);
|
||||
* - a draft trip can be deleted;
|
||||
* - the odometer CHECK rejects an arrival reading at or below the start.
|
||||
*/
|
||||
|
||||
async function insertTrip(params: {
|
||||
companyId: string
|
||||
userId: string
|
||||
status?: 'draft' | 'booked'
|
||||
odometerStart?: number | null
|
||||
odometerEnd?: number | null
|
||||
}): Promise<string> {
|
||||
const res = await getPool().query<{ id: string }>(
|
||||
`INSERT INTO public.mileage_trips
|
||||
(company_id, user_id, trip_date, distance_km, from_location,
|
||||
to_location, purpose, status, odometer_start, odometer_end)
|
||||
VALUES ($1, $2, '2026-05-10', 32.3, 'Kontoret', 'Kunden', 'Kundbesök',
|
||||
$3, $4, $5)
|
||||
RETURNING id`,
|
||||
[
|
||||
params.companyId,
|
||||
params.userId,
|
||||
params.status ?? 'draft',
|
||||
params.odometerStart ?? null,
|
||||
params.odometerEnd ?? null,
|
||||
],
|
||||
)
|
||||
return res.rows[0].id
|
||||
}
|
||||
|
||||
describe('mileage_trips RLS', () => {
|
||||
it('shows a member their company trips and hides other companies', async () => {
|
||||
const a = await seedCompany()
|
||||
const b = await seedCompany()
|
||||
await insertTrip({ companyId: a.companyId, userId: a.userId })
|
||||
await insertTrip({ companyId: b.companyId, userId: b.userId })
|
||||
|
||||
const visibleToA = await withUserContext(a.userId, async (client) => {
|
||||
const res = await client.query(`SELECT company_id FROM public.mileage_trips`)
|
||||
return res.rows
|
||||
})
|
||||
expect(visibleToA).toHaveLength(1)
|
||||
expect(visibleToA[0].company_id).toBe(a.companyId)
|
||||
})
|
||||
|
||||
it('blocks inserting a trip into a foreign company', async () => {
|
||||
const a = await seedCompany()
|
||||
const b = await seedCompany()
|
||||
await expect(
|
||||
withUserContext(a.userId, (client) =>
|
||||
client.query(
|
||||
`INSERT INTO public.mileage_trips
|
||||
(company_id, user_id, trip_date, distance_km, from_location, to_location, purpose)
|
||||
VALUES ($1, $2, '2026-05-10', 10, 'A', 'B', 'Test')`,
|
||||
[b.companyId, a.userId],
|
||||
),
|
||||
),
|
||||
).rejects.toThrow(/row-level security/)
|
||||
})
|
||||
})
|
||||
|
||||
describe('mileage_trips retention trigger', () => {
|
||||
it('blocks deleting a booked trip (BFL underlag)', async () => {
|
||||
const { companyId, userId } = await seedCompany()
|
||||
const tripId = await insertTrip({ companyId, userId, status: 'booked' })
|
||||
await expect(
|
||||
getPool().query(`DELETE FROM public.mileage_trips WHERE id = $1`, [tripId]),
|
||||
).rejects.toThrow(/booked mileage trip/)
|
||||
})
|
||||
|
||||
it('allows deleting a draft trip', async () => {
|
||||
const { companyId, userId } = await seedCompany()
|
||||
const tripId = await insertTrip({ companyId, userId, status: 'draft' })
|
||||
const res = await getPool().query(
|
||||
`DELETE FROM public.mileage_trips WHERE id = $1`,
|
||||
[tripId],
|
||||
)
|
||||
expect(res.rowCount).toBe(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('mileage_trips booked immutability (20260807113215)', () => {
|
||||
it('blocks changing core fields on a booked trip', async () => {
|
||||
const { companyId, userId } = await seedCompany()
|
||||
const tripId = await insertTrip({ companyId, userId, status: 'booked' })
|
||||
await expect(
|
||||
getPool().query(`UPDATE public.mileage_trips SET distance_km = 999 WHERE id = $1`, [tripId]),
|
||||
).rejects.toThrow(/booked mileage trip/)
|
||||
})
|
||||
|
||||
it('allows a notes-only edit on a booked trip', async () => {
|
||||
const { companyId, userId } = await seedCompany()
|
||||
const tripId = await insertTrip({ companyId, userId, status: 'booked' })
|
||||
const res = await getPool().query(
|
||||
`UPDATE public.mileage_trips SET notes = 'anteckning' WHERE id = $1`,
|
||||
[tripId],
|
||||
)
|
||||
expect(res.rowCount).toBe(1)
|
||||
})
|
||||
|
||||
it('allows reverting an UNLINKED claim back to draft', async () => {
|
||||
const { companyId, userId } = await seedCompany()
|
||||
const tripId = await insertTrip({ companyId, userId, status: 'booked' })
|
||||
const res = await getPool().query(
|
||||
`UPDATE public.mileage_trips SET status = 'draft' WHERE id = $1`,
|
||||
[tripId],
|
||||
)
|
||||
expect(res.rowCount).toBe(1)
|
||||
})
|
||||
|
||||
it('forces a revert to draft to clear salary_run_id (20260807114924)', async () => {
|
||||
const { companyId, userId } = await seedCompany()
|
||||
const runRes = await getPool().query<{ id: string }>(
|
||||
`INSERT INTO public.salary_runs (company_id, user_id, period_year, period_month, payment_date)
|
||||
VALUES ($1, $2, 2026, 5, '2026-05-25') RETURNING id`,
|
||||
[companyId, userId],
|
||||
)
|
||||
const runId = runRes.rows[0].id
|
||||
const tripId = await insertTrip({ companyId, userId, status: 'booked' })
|
||||
await getPool().query(
|
||||
`UPDATE public.mileage_trips SET salary_run_id = $2 WHERE id = $1`,
|
||||
[tripId, runId],
|
||||
)
|
||||
// Revert keeping salary_run_id: rejected (draft trip would still carry a
|
||||
// run that holds its allowance = re-bookable double pay).
|
||||
await expect(
|
||||
getPool().query(`UPDATE public.mileage_trips SET status = 'draft' WHERE id = $1`, [tripId]),
|
||||
).rejects.toThrow(/clear salary_run_id/)
|
||||
// Revert clearing it in the same statement: allowed.
|
||||
const res = await getPool().query(
|
||||
`UPDATE public.mileage_trips SET status = 'draft', salary_run_id = NULL WHERE id = $1`,
|
||||
[tripId],
|
||||
)
|
||||
expect(res.rowCount).toBe(1)
|
||||
})
|
||||
|
||||
it('blocks unbooking a trip linked to a verifikat', async () => {
|
||||
const { companyId, userId, fiscalPeriodId } = await seedCompany()
|
||||
const entryId = await insertPostedJournalEntry({ companyId, userId, fiscalPeriodId })
|
||||
const tripId = await insertTrip({ companyId, userId, status: 'booked' })
|
||||
await getPool().query(
|
||||
`UPDATE public.mileage_trips SET journal_entry_id = $2 WHERE id = $1`,
|
||||
[tripId, entryId],
|
||||
)
|
||||
await expect(
|
||||
getPool().query(`UPDATE public.mileage_trips SET status = 'draft' WHERE id = $1`, [tripId]),
|
||||
).rejects.toThrow(/linked to a verifikat/)
|
||||
})
|
||||
})
|
||||
|
||||
describe('mileage_trips constraints', () => {
|
||||
it('rejects an arrival odometer at or below the start reading', async () => {
|
||||
const { companyId, userId } = await seedCompany()
|
||||
await expect(
|
||||
insertTrip({ companyId, userId, odometerStart: 1032, odometerEnd: 1000 }),
|
||||
).rejects.toThrow(/mileage_trips_odometer_order/)
|
||||
})
|
||||
|
||||
it('accepts a plain km distance without odometer readings', async () => {
|
||||
const { companyId, userId } = await seedCompany()
|
||||
const id = await insertTrip({ companyId, userId })
|
||||
expect(id).toBeTruthy()
|
||||
})
|
||||
})
|
||||
@@ -2217,6 +2217,10 @@ export type PendingOperationType =
|
||||
// Payroll: salary run creation + AGI declaration
|
||||
| 'create_salary_run'
|
||||
| 'generate_agi'
|
||||
// Körjournal: log a trip (pure travel documentation) + book the period's
|
||||
// milersättning as one verifikat (7331 at schablon rate)
|
||||
| 'log_mileage_trip'
|
||||
| 'book_mileage_period'
|
||||
// Mark invoice paid by linking an existing posted verifikat (no new JE)
|
||||
| 'link_invoice_voucher'
|
||||
// Supplier-side mirror: mark a leverantörsfaktura paid by linking an existing
|
||||
@@ -4184,3 +4188,64 @@ export interface StoredStagedOperation {
|
||||
risk_level?: string | null
|
||||
preview_data?: unknown
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Körjournal (mileage trips)
|
||||
// ============================================================
|
||||
|
||||
export type MileageVehicleType = 'own_car' | 'company_car_fossil' | 'company_car_electric'
|
||||
|
||||
export type MileageTripStatus = 'draft' | 'booked'
|
||||
|
||||
/** A `mileage_trips` row: one business trip in the körjournal. */
|
||||
export interface MileageTrip {
|
||||
id: string
|
||||
company_id: string
|
||||
user_id: string
|
||||
employee_id: string | null
|
||||
trip_date: string
|
||||
vehicle_type: MileageVehicleType
|
||||
vehicle_registration: string | null
|
||||
odometer_start: number | null
|
||||
odometer_end: number | null
|
||||
distance_km: number
|
||||
from_location: string
|
||||
to_location: string
|
||||
purpose: string
|
||||
visited: string | null
|
||||
is_round_trip: boolean
|
||||
status: MileageTripStatus
|
||||
journal_entry_id: string | null
|
||||
salary_run_id: string | null
|
||||
notes: string | null
|
||||
created_via: 'manual' | 'mcp' | 'import'
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
export interface CreateMileageTripInput {
|
||||
trip_date: string
|
||||
vehicle_type?: MileageVehicleType
|
||||
vehicle_registration?: string | null
|
||||
odometer_start?: number | null
|
||||
odometer_end?: number | null
|
||||
distance_km: number
|
||||
from_location: string
|
||||
to_location: string
|
||||
purpose: string
|
||||
visited?: string | null
|
||||
is_round_trip?: boolean
|
||||
employee_id?: string | null
|
||||
notes?: string | null
|
||||
created_via?: 'manual' | 'mcp' | 'import'
|
||||
}
|
||||
|
||||
/** Per-vehicle-type aggregation of draft trips for a period. */
|
||||
export interface MileagePeriodSummary {
|
||||
vehicle_type: MileageVehicleType
|
||||
trip_count: number
|
||||
total_km: number
|
||||
total_mil: number
|
||||
rate_per_mil: number
|
||||
amount: number
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user