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:
Mattsson
2026-08-07 14:17:21 +02:00
committed by GitHub
co-authored by Claude Fable 5
parent f7f3a31f8e
commit 7411a0171b
30 changed files with 3656 additions and 2 deletions
+649
View File
@@ -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,
})
})
})
+79
View File
@@ -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 }
)
+63
View File
@@ -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"`,
},
})
})
+61
View File
@@ -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)
})
})
+122
View File
@@ -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')
})
})
+34
View File
@@ -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 }
)