From 7411a0171bc5918b935364ad13413dccab2c324a Mon Sep 17 00:00:00 2001 From: Mattsson <111893710+mattssonn@users.noreply.github.com> Date: Fri, 7 Aug 2026 14:17:21 +0200 Subject: [PATCH] =?UTF-8?q?feat(mileage):=20k=C3=B6rjournal=20with=20miler?= =?UTF-8?q?s=C3=A4ttning=20booking,=20MCP=20tools=20and=20CSV=20export=20(?= =?UTF-8?q?#1448)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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 * refactor(mileage): use shared roundOre helper per tightened ratchet baseline Co-Authored-By: Claude Fable 5 * 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 * 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 * 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 * 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 * fix(mileage): company-scope employee_id on PATCH (Superagent P2) Co-Authored-By: Claude Fable 5 * test(mileage): valid v4 uuid in cross-company employee PATCH test Co-Authored-By: Claude Fable 5 --------- Co-authored-by: Claude Fable 5 --- DECISIONS.md | 3 + app/(dashboard)/mileage/page.tsx | 649 ++++++++++++++++++ app/api/mileage/book/__tests__/route.test.ts | 130 ++++ app/api/mileage/book/route.ts | 79 +++ app/api/mileage/export/route.ts | 63 ++ app/api/mileage/salary-push/route.ts | 61 ++ .../trips/[id]/__tests__/route.test.ts | 160 +++++ app/api/mileage/trips/[id]/route.ts | 122 ++++ app/api/mileage/trips/__tests__/route.test.ts | 131 ++++ app/api/mileage/trips/route.ts | 34 + components/dashboard/DashboardNav.tsx | 3 + extensions/general/mcp-server/server.ts | 240 +++++++ lib/api/schemas.ts | 77 +++ lib/auth/api-keys.ts | 9 +- lib/mileage/__tests__/csv-export.test.ts | 82 +++ lib/mileage/__tests__/mileage-service.test.ts | 449 ++++++++++++ lib/mileage/csv-export.ts | 76 ++ lib/mileage/mileage-service.ts | 499 ++++++++++++++ lib/pending-operations/commit.ts | 106 +++ lib/pending-operations/risk-tiers.ts | 10 + lib/reports/full-archive-export.ts | 3 + messages/en.json | 64 ++ messages/sv.json | 64 ++ .../20260807084705_mileage_trips.sql | 78 +++ ...7093856_pending_operations_add_mileage.sql | 77 +++ ...06_validate_pending_operations_mileage.sql | 5 + ...3215_mileage_trips_booked_immutability.sql | 75 ++ ...mileage_trips_revert_clears_salary_run.sql | 71 ++ .../__tests__/mileage-trips.pg.test.ts | 173 +++++ types/index.ts | 65 ++ 30 files changed, 3656 insertions(+), 2 deletions(-) create mode 100644 app/(dashboard)/mileage/page.tsx create mode 100644 app/api/mileage/book/__tests__/route.test.ts create mode 100644 app/api/mileage/book/route.ts create mode 100644 app/api/mileage/export/route.ts create mode 100644 app/api/mileage/salary-push/route.ts create mode 100644 app/api/mileage/trips/[id]/__tests__/route.test.ts create mode 100644 app/api/mileage/trips/[id]/route.ts create mode 100644 app/api/mileage/trips/__tests__/route.test.ts create mode 100644 app/api/mileage/trips/route.ts create mode 100644 lib/mileage/__tests__/csv-export.test.ts create mode 100644 lib/mileage/__tests__/mileage-service.test.ts create mode 100644 lib/mileage/csv-export.ts create mode 100644 lib/mileage/mileage-service.ts create mode 100644 supabase/migrations/20260807084705_mileage_trips.sql create mode 100644 supabase/migrations/20260807093856_pending_operations_add_mileage.sql create mode 100644 supabase/migrations/20260807093906_validate_pending_operations_mileage.sql create mode 100644 supabase/migrations/20260807113215_mileage_trips_booked_immutability.sql create mode 100644 supabase/migrations/20260807114924_mileage_trips_revert_clears_salary_run.sql create mode 100644 supabase/migrations/__tests__/mileage-trips.pg.test.ts diff --git a/DECISIONS.md b/DECISIONS.md index 5caf5b43..f6e32666 100644 --- a/DECISIONS.md +++ b/DECISIONS.md @@ -818,6 +818,9 @@ One line per decision: `[YYYY-MM-DD] : `. 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. diff --git a/app/(dashboard)/mileage/page.tsx b/app/(dashboard)/mileage/page.tsx new file mode 100644 index 00000000..fa6b5271 --- /dev/null +++ b/app/(dashboard)/mileage/page.tsx @@ -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([]) + const [loading, setLoading] = useState(true) + const [month, setMonth] = useState('all') + + const [formOpen, setFormOpen] = useState(false) + const [editingId, setEditingId] = useState(null) + const [form, setForm] = useState(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 ( +
+ + + {t('new_trip')} + + ) : undefined + } + /> + +
+ {draftTrips.length > 0 && ( +

+ {t('draft_summary', { count: draftTrips.length, km: draftKm })} +

+ )} +
+ {canWrite && draftTrips.length > 0 && ( + + )} + {trips.length > 0 && ( + + )} + ({ id: m, label: m })), + ]} + value={month} + onChange={setMonth} + triggerLabel={monthLabel} + ariaLabel={t('month_filter')} + /> +
+
+ + {loading ? ( +
+ + + +
+ ) : visibleTrips.length === 0 ? ( + + ) : ( + + + + + + + + + + + + {visibleTrips.map((trip) => ( + + + + + + + + + ))} + +
{t('col_date')}{t('col_route')}{t('col_purpose')}{t('col_km')}{t('col_status')} +
+ {formatDate(trip.trip_date)} + + {trip.from_location} – {trip.to_location} + {trip.is_round_trip ? ` ${t('round_trip_suffix')}` : ''} + {trip.purpose} + {Number(trip.distance_km).toLocaleString('sv-SE')} + + {trip.status === 'draft' ? ( + {t('status_draft')} + ) : ( + {t('status_booked')} + )} + + {canWrite && ( + + + {trip.status === 'draft' && ( + <> + + + + )} + + )} +
+ )} + + + + + {editingId ? t('edit_trip') : t('new_trip')} + +
+
+
+ + setForm({ ...form, trip_date: e.target.value })} + /> +
+
+ + +
+
+
+
+ + setForm({ ...form, from_location: e.target.value })} + /> +
+
+ + setForm({ ...form, to_location: e.target.value })} + /> +
+
+
+ + setForm({ ...form, purpose: e.target.value })} + /> +
+
+
+ + setForm({ ...form, distance_km: e.target.value })} + /> +
+ {!editingId && ( +
+ +
+ )} +
+ + + {showMore && ( +
+
+
+ + setForm({ ...form, vehicle_registration: e.target.value })} + /> +
+
+ + setForm({ ...form, odometer_start: e.target.value })} + /> +
+
+ + setForm({ ...form, odometer_end: e.target.value })} + /> +
+
+
+ + setForm({ ...form, visited: e.target.value })} + /> +
+
+ + setForm({ ...form, notes: e.target.value })} + /> +
+
+ )} + +
+ + +
+
+
+
+ + + + + {t('book_period')} + +
+
+
+ + setBookFrom(e.target.value)} + /> +
+
+ + setBookTo(e.target.value)} + /> +
+
+
+ + setBookEntryDate(e.target.value)} + /> +
+
+ + +
+

{t('book_explainer')}

+
+ + +
+
+
+
+
+ ) +} diff --git a/app/api/mileage/book/__tests__/route.test.ts b/app/api/mileage/book/__tests__/route.test.ts new file mode 100644 index 00000000..9f22b0a0 --- /dev/null +++ b/app/api/mileage/book/__tests__/route.test.ts @@ -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, + }) + }) +}) diff --git a/app/api/mileage/book/route.ts b/app/api/mileage/book/route.ts new file mode 100644 index 00000000..0023acb8 --- /dev/null +++ b/app/api/mileage/book/route.ts @@ -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 } +) diff --git a/app/api/mileage/export/route.ts b/app/api/mileage/export/route.ts new file mode 100644 index 00000000..375181c8 --- /dev/null +++ b/app/api/mileage/export/route.ts @@ -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() + 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() + 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"`, + }, + }) +}) diff --git a/app/api/mileage/salary-push/route.ts b/app/api/mileage/salary-push/route.ts new file mode 100644 index 00000000..06671c40 --- /dev/null +++ b/app/api/mileage/salary-push/route.ts @@ -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 } +) diff --git a/app/api/mileage/trips/[id]/__tests__/route.test.ts b/app/api/mileage/trips/[id]/__tests__/route.test.ts new file mode 100644 index 00000000..4793dc49 --- /dev/null +++ b/app/api/mileage/trips/[id]/__tests__/route.test.ts @@ -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 = {} + 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 + 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) + }) +}) diff --git a/app/api/mileage/trips/[id]/route.ts b/app/api/mileage/trips/[id]/route.ts new file mode 100644 index 00000000..4ec8787e --- /dev/null +++ b/app/api/mileage/trips/[id]/route.ts @@ -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( + '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( + '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 } +) diff --git a/app/api/mileage/trips/__tests__/route.test.ts b/app/api/mileage/trips/__tests__/route.test.ts new file mode 100644 index 00000000..21631f87 --- /dev/null +++ b/app/api/mileage/trips/__tests__/route.test.ts @@ -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') + }) +}) diff --git a/app/api/mileage/trips/route.ts b/app/api/mileage/trips/route.ts new file mode 100644 index 00000000..a575b705 --- /dev/null +++ b/app/api/mileage/trips/route.ts @@ -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 } +) diff --git a/components/dashboard/DashboardNav.tsx b/components/dashboard/DashboardNav.tsx index 3bd34c3e..ed0b6892 100644 --- a/components/dashboard/DashboardNav.tsx +++ b/components/dashboard/DashboardNav.tsx @@ -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' }, diff --git a/extensions/general/mcp-server/server.ts b/extensions/general/mcp-server/server.ts index 3fc9dcf1..66f3108e 100644 --- a/extensions/general/mcp-server/server.ts +++ b/extensions/general/mcp-server/server.ts @@ -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', diff --git a/lib/api/schemas.ts b/lib/api/schemas.ts index d1b7bb3d..7506f4b1 100644 --- a/lib/api/schemas.ts +++ b/lib/api/schemas.ts @@ -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', + }) diff --git a/lib/auth/api-keys.ts b/lib/auth/api-keys.ts index 56ffe25c..e3ce346d 100644 --- a/lib/auth/api-keys.ts +++ b/lib/auth/api-keys.ts @@ -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 = { 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', diff --git a/lib/mileage/__tests__/csv-export.test.ts b/lib/mileage/__tests__/csv-export.test.ts new file mode 100644 index 00000000..69576533 --- /dev/null +++ b/lib/mileage/__tests__/csv-export.test.ts @@ -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 { + 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') + }) +}) diff --git a/lib/mileage/__tests__/mileage-service.test.ts b/lib/mileage/__tests__/mileage-service.test.ts new file mode 100644 index 00000000..9d25a402 --- /dev/null +++ b/lib/mileage/__tests__/mileage-service.test.ts @@ -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() + return { ...actual, createJournalEntry: vi.fn() } +}) +vi.mock('@/lib/salary/payroll-config', async (importOriginal) => { + const actual = await importOriginal() + 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 { + 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 = {} + 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 = {} + 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).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 = {} + 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' }) + }) +}) diff --git a/lib/mileage/csv-export.ts b/lib/mileage/csv-export.ts new file mode 100644 index 00000000..fbd844ac --- /dev/null +++ b/lib/mileage/csv-export.ts @@ -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 = { + 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 = new Map(), + driverLabels: Map = 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' +} diff --git a/lib/mileage/mileage-service.ts b/lib/mileage/mileage-service.ts new file mode 100644 index 00000000..2bd4668a --- /dev/null +++ b/lib/mileage/mileage-service.ts @@ -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 = { + 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[], + config: PayrollConfig +): MileagePeriodSummary[] { + const groups = new Map() + 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 { + return fetchAllRows(({ 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 { + // 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 { + // 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 { + 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 } +} diff --git a/lib/pending-operations/commit.ts b/lib/pending-operations/commit.ts index 45282db8..7caa0c95 100644 --- a/lib/pending-operations/commit.ts +++ b/lib/pending-operations/commit.ts @@ -4661,6 +4661,106 @@ async function commitCreateSalaryRun( } } +async function commitLogMileageTrip( + supabase: SupabaseClient, + userId: string, + companyId: string, + params: Record +): Promise { + 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 +): Promise { + 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 diff --git a/lib/pending-operations/risk-tiers.ts b/lib/pending-operations/risk-tiers.ts index f38687d2..cf85a5c4 100644 --- a/lib/pending-operations/risk-tiers.ts +++ b/lib/pending-operations/risk-tiers.ts @@ -198,6 +198,16 @@ export const OPERATION_RISK_TIERS: Record = { // 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. diff --git a/lib/reports/full-archive-export.ts b/lib/reports/full-archive-export.ts index 046931e7..b8522455 100644 --- a/lib/reports/full-archive-export.ts +++ b/lib/reports/full-archive-export.ts @@ -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' }, diff --git a/messages/en.json b/messages/en.json index 7aee1abc..50415560 100644 --- a/messages/en.json +++ b/messages/en.json @@ -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}" } } diff --git a/messages/sv.json b/messages/sv.json index 69255b88..318f84b8 100644 --- a/messages/sv.json +++ b/messages/sv.json @@ -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}" } } diff --git a/supabase/migrations/20260807084705_mileage_trips.sql b/supabase/migrations/20260807084705_mileage_trips.sql new file mode 100644 index 00000000..59665590 --- /dev/null +++ b/supabase/migrations/20260807084705_mileage_trips.sql @@ -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'; diff --git a/supabase/migrations/20260807093856_pending_operations_add_mileage.sql b/supabase/migrations/20260807093856_pending_operations_add_mileage.sql new file mode 100644 index 00000000..268cbe6b --- /dev/null +++ b/supabase/migrations/20260807093856_pending_operations_add_mileage.sql @@ -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'; diff --git a/supabase/migrations/20260807093906_validate_pending_operations_mileage.sql b/supabase/migrations/20260807093906_validate_pending_operations_mileage.sql new file mode 100644 index 00000000..dbeb79a3 --- /dev/null +++ b/supabase/migrations/20260807093906_validate_pending_operations_mileage.sql @@ -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; diff --git a/supabase/migrations/20260807113215_mileage_trips_booked_immutability.sql b/supabase/migrations/20260807113215_mileage_trips_booked_immutability.sql new file mode 100644 index 00000000..77f38963 --- /dev/null +++ b/supabase/migrations/20260807113215_mileage_trips_booked_immutability.sql @@ -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'; diff --git a/supabase/migrations/20260807114924_mileage_trips_revert_clears_salary_run.sql b/supabase/migrations/20260807114924_mileage_trips_revert_clears_salary_run.sql new file mode 100644 index 00000000..cce041ba --- /dev/null +++ b/supabase/migrations/20260807114924_mileage_trips_revert_clears_salary_run.sql @@ -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; +$$; diff --git a/supabase/migrations/__tests__/mileage-trips.pg.test.ts b/supabase/migrations/__tests__/mileage-trips.pg.test.ts new file mode 100644 index 00000000..cc647296 --- /dev/null +++ b/supabase/migrations/__tests__/mileage-trips.pg.test.ts @@ -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 { + 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() + }) +}) diff --git a/types/index.ts b/types/index.ts index 4ae31717..eb58cf10 100644 --- a/types/index.ts +++ b/types/index.ts @@ -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 +}