feat(mileage): körjournal with milersättning booking, MCP tools and CSV export (#1448)

* feat(mileage): körjournal with milersättning booking, MCP tools and CSV export

New mileage_trips table (RLS, booked-delete trigger per BFL retention),
lib/mileage service reusing the payroll schablon rates, /api/mileage routes
(trips CRUD, period booking to 7331, salary-run push, körjournal CSV),
Körjournal dashboard page + nav, and three staged MCP tools (search-only
catalog). Trips book as one verifikat per period via the engine; salary
path inserts mileage_taxfree line items. mileage_trips classified in the
full-archive export.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* refactor(mileage): use shared roundOre helper per tightened ratchet baseline

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(mileage): pending_operations op-type migration + Swedish review findings

- New migration pair adds log_mileage_trip/book_mileage_period to the
  pending_operations operation_type CHECK (pg-real audit).
- bookMileagePeriod refuses a period spanning several employees and names
  the employee in the verifikationstext when scoped (BFL motpart).
- vehicle_registration required for förmånsbil trips (schema, service,
  MCP staging, UI surfaces the field).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(mileage): claim-first booking, CSV injection guard and driver column

- bookMileagePeriod claims trips (draft to booked CAS) before creating the
  verifikat, so a concurrent second booking loses the race instead of
  double-booking; claim reverts if verifikat creation fails.
- Körjournal CSV neutralizes formula-injection triggers (OWASP) and adds a
  Förare column naming the employee per trip.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(mileage): resolve CodeRabbit + Swedish review round: race, drift and hardening

- Copying a round trip no longer re-doubles the stored distance.
- pushMileageToSalaryRun claims trips before inserting line items (retry can
  no longer double-pay); CLAIM_LOST replaces misleading NO_TRIPS on lost races.
- Booked trips are DB-immutable via a BEFORE UPDATE trigger (new migration
  20260807113215): only claim/link/revert transitions and notes edits pass.
- Cross-year periods rejected (schablon rates are per calendar year); payroll
  config year read from the date string, not TZ-dependent getFullYear().
- MCP staged bookings freeze the previewed trip set (trip_ids in params) and
  the commit fails on drift; validation errors return 400, not 500.
- PATCH enforces the förmånsbil regnr rule on the effective row; export
  validates dates before they reach the Content-Disposition header; employee_id
  is verified company-scoped on trip creation; stale orphaned claims released.
- UI: fetch flags reset in finally; ICU plural for draft summary; distance
  stored at the column's 1-decimal precision.
- Tests: [id] route suite, pushMileageToSalaryRun suite, claim-race, drift,
  cross-year and update-trigger pg cases.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(mileage): revert-to-draft must clear salary_run_id at the trigger level

New migration 20260807114924 replaces the booked-immutability function: a
booked -> draft revert now rejects rows keeping salary_run_id, closing the
DB-level double-pay path CodeRabbit flagged. pg test pins both directions;
the CLAIM_LOST unit test now asserts the revert.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(mileage): company-scope employee_id on PATCH (Superagent P2)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* test(mileage): valid v4 uuid in cross-company employee PATCH test

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Mattsson
2026-08-07 14:17:21 +02:00
committed by GitHub
co-authored by Claude Fable 5
parent f7f3a31f8e
commit 7411a0171b
30 changed files with 3656 additions and 2 deletions
@@ -0,0 +1,78 @@
-- Körjournal: mileage trip log per Skatteverket documentation requirements.
-- Trips are drafts until booked; a booked trip is underlag for a verifikat
-- (or a salary run line) and falls under BFL 7-year retention, so booked
-- rows can never be deleted.
CREATE TABLE public.mileage_trips (
id uuid DEFAULT gen_random_uuid() PRIMARY KEY,
company_id uuid NOT NULL REFERENCES public.companies(id) ON DELETE CASCADE,
user_id uuid NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE,
employee_id uuid REFERENCES public.employees(id) ON DELETE SET NULL,
trip_date date NOT NULL,
vehicle_type text NOT NULL DEFAULT 'own_car'
CHECK (vehicle_type IN ('own_car', 'company_car_fossil', 'company_car_electric')),
vehicle_registration text,
odometer_start integer CHECK (odometer_start >= 0),
odometer_end integer CHECK (odometer_end >= 0),
distance_km numeric(10,1) NOT NULL CHECK (distance_km > 0),
from_location text NOT NULL,
to_location text NOT NULL,
purpose text NOT NULL,
visited text,
is_round_trip boolean NOT NULL DEFAULT false,
status text NOT NULL DEFAULT 'draft' CHECK (status IN ('draft', 'booked')),
journal_entry_id uuid REFERENCES public.journal_entries(id) ON DELETE SET NULL,
salary_run_id uuid REFERENCES public.salary_runs(id) ON DELETE SET NULL,
notes text,
created_via text NOT NULL DEFAULT 'manual' CHECK (created_via IN ('manual', 'mcp', 'import')),
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
CONSTRAINT mileage_trips_odometer_order
CHECK (odometer_start IS NULL OR odometer_end IS NULL OR odometer_end > odometer_start)
);
ALTER TABLE public.mileage_trips ENABLE ROW LEVEL SECURITY;
CREATE POLICY "view own-company mileage_trips"
ON public.mileage_trips FOR SELECT USING (company_id IN (SELECT user_company_ids()));
CREATE POLICY "insert own-company mileage_trips"
ON public.mileage_trips FOR INSERT WITH CHECK (company_id IN (SELECT user_company_ids()));
CREATE POLICY "update own-company mileage_trips"
ON public.mileage_trips FOR UPDATE USING (company_id IN (SELECT user_company_ids()));
CREATE POLICY "delete own-company mileage_trips"
ON public.mileage_trips FOR DELETE USING (company_id IN (SELECT user_company_ids()));
CREATE INDEX idx_mileage_trips_company_date ON public.mileage_trips (company_id, trip_date DESC);
CREATE INDEX idx_mileage_trips_company_status ON public.mileage_trips (company_id, status);
CREATE INDEX idx_mileage_trips_journal_entry
ON public.mileage_trips (journal_entry_id) WHERE journal_entry_id IS NOT NULL;
CREATE TRIGGER set_updated_at_mileage_trips
BEFORE UPDATE ON public.mileage_trips
FOR EACH ROW EXECUTE FUNCTION public.update_updated_at_column();
CREATE TRIGGER audit_mileage_trips
AFTER INSERT OR UPDATE OR DELETE ON public.mileage_trips
FOR EACH ROW EXECUTE FUNCTION public.write_audit_log();
-- A booked trip is bookkeeping underlag (BFL 7 kap): block deletion.
CREATE OR REPLACE FUNCTION public.block_booked_mileage_trip_deletion()
RETURNS trigger
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = public
AS $$
BEGIN
IF OLD.status = 'booked' THEN
RAISE EXCEPTION 'Cannot delete a booked mileage trip: it is retained as underlag (BFL). Reverse the verifikat first.'
USING ERRCODE = 'P0001';
END IF;
RETURN OLD;
END;
$$;
CREATE TRIGGER block_booked_mileage_trip_deletion
BEFORE DELETE ON public.mileage_trips
FOR EACH ROW EXECUTE FUNCTION public.block_booked_mileage_trip_deletion();
NOTIFY pgrst, 'reload schema';
@@ -0,0 +1,77 @@
-- Add log_mileage_trip and book_mileage_period to the pending_operations
-- operation type CHECK (korjournal MCP tools, PR #1448).
--
-- NOTE on the value list: this constraint is re-created wholesale, so the list
-- below is every value from 20260727110000 PLUS the two new mileage values.
ALTER TABLE public.pending_operations
DROP CONSTRAINT IF EXISTS pending_operations_operation_type_check;
ALTER TABLE public.pending_operations
ADD CONSTRAINT pending_operations_operation_type_check
CHECK (operation_type IN (
'categorize_transaction',
'create_customer',
'create_invoice',
'mark_invoice_paid',
'send_invoice',
'mark_invoice_sent',
'match_transaction_invoice',
'close_period',
'lock_period',
'unlock_period',
'set_opening_balances',
'run_year_end',
'run_currency_revaluation',
'import_sie',
'explain_voucher_gap',
'uncategorize_transaction',
'approve_supplier_invoice',
'credit_supplier_invoice',
'credit_invoice',
'convert_invoice',
'create_transaction',
'attach_document_to_transaction',
'create_voucher',
'correct_entry',
'reverse_entry',
'create_supplier',
'create_supplier_invoice_from_inbox',
'post_annual_depreciation',
'link_invoice_voucher',
'undo_sie_import',
'match_batch_allocate',
'bulk_book_transactions',
'create_salary_run',
'generate_agi',
'link_transaction_journal_entry',
'link_supplier_invoice_voucher',
'submit_vat_declaration',
'submit_agi',
'create_article',
'update_article',
'bulk_book_inbox_items',
'create_dimension_value',
'retag_line_dimensions',
'link_document_to_voucher',
'update_payslip_line',
'register_absence',
'create_employee',
'update_employee',
'set_employee_opening_balances',
'vacation_year_close',
'create_account',
'update_account',
'set_voucher_note',
'book_salary_run',
'delete_absence',
'update_company_settings',
'update_customer',
'update_invoice',
'create_recurring_schedule',
'update_recurring_schedule',
'log_mileage_trip',
'book_mileage_period'
)) NOT VALID;
NOTIFY pgrst, 'reload schema';
@@ -0,0 +1,5 @@
-- Validate the operation type CHECK re-added in 20260807093856.
-- Separate transaction to avoid a full-table scan under the stronger lock.
ALTER TABLE public.pending_operations
VALIDATE CONSTRAINT pending_operations_operation_type_check;
@@ -0,0 +1,75 @@
-- Booked mileage trips are korjournal underlag for a posted verifikat:
-- immutable at the database layer (BFL 5 kap 5 §, 7 kap), mirroring the
-- delete block from 20260807084705. Allowed transitions only:
-- * draft -> booked (the booking service's claim; may set salary_run_id)
-- * booked -> draft revert of an UNLINKED claim (journal_entry_id IS NULL),
-- clearing salary_run_id
-- * booked -> booked filling journal_entry_id / salary_run_id from NULL
-- * notes may always change (annotation, mirrors the verifikat-notes
-- carve-out); everything else on a booked row is frozen.
CREATE OR REPLACE FUNCTION public.enforce_booked_mileage_trip_immutability()
RETURNS trigger
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = public
AS $$
DECLARE
core_changed boolean;
BEGIN
IF OLD.status <> 'booked' THEN
RETURN NEW;
END IF;
core_changed :=
NEW.trip_date IS DISTINCT FROM OLD.trip_date
OR NEW.vehicle_type IS DISTINCT FROM OLD.vehicle_type
OR NEW.vehicle_registration IS DISTINCT FROM OLD.vehicle_registration
OR NEW.odometer_start IS DISTINCT FROM OLD.odometer_start
OR NEW.odometer_end IS DISTINCT FROM OLD.odometer_end
OR NEW.distance_km IS DISTINCT FROM OLD.distance_km
OR NEW.from_location IS DISTINCT FROM OLD.from_location
OR NEW.to_location IS DISTINCT FROM OLD.to_location
OR NEW.purpose IS DISTINCT FROM OLD.purpose
OR NEW.visited IS DISTINCT FROM OLD.visited
OR NEW.is_round_trip IS DISTINCT FROM OLD.is_round_trip
OR NEW.employee_id IS DISTINCT FROM OLD.employee_id
OR NEW.company_id IS DISTINCT FROM OLD.company_id
OR NEW.user_id IS DISTINCT FROM OLD.user_id
OR NEW.created_via IS DISTINCT FROM OLD.created_via;
IF core_changed THEN
RAISE EXCEPTION 'Cannot modify a booked mileage trip: it is retained as underlag (BFL). Reverse the verifikat first.'
USING ERRCODE = 'P0001';
END IF;
-- Revert of an unlinked claim back to draft.
IF NEW.status = 'draft' THEN
IF OLD.journal_entry_id IS NOT NULL THEN
RAISE EXCEPTION 'Cannot unbook a mileage trip linked to a verifikat. Reverse the verifikat first.'
USING ERRCODE = 'P0001';
END IF;
RETURN NEW;
END IF;
-- Booked stays booked: links may only be set from NULL, never rewritten.
IF OLD.journal_entry_id IS NOT NULL
AND NEW.journal_entry_id IS DISTINCT FROM OLD.journal_entry_id THEN
RAISE EXCEPTION 'Cannot repoint a booked mileage trip to another verifikat.'
USING ERRCODE = 'P0001';
END IF;
IF OLD.salary_run_id IS NOT NULL
AND NEW.salary_run_id IS DISTINCT FROM OLD.salary_run_id THEN
RAISE EXCEPTION 'Cannot repoint a booked mileage trip to another salary run.'
USING ERRCODE = 'P0001';
END IF;
RETURN NEW;
END;
$$;
CREATE TRIGGER enforce_booked_mileage_trip_immutability
BEFORE UPDATE ON public.mileage_trips
FOR EACH ROW EXECUTE FUNCTION public.enforce_booked_mileage_trip_immutability();
NOTIFY pgrst, 'reload schema';
@@ -0,0 +1,71 @@
-- Tighten the booked -> draft revert rule from 20260807113215: a revert must
-- also leave the trip detached from any salary run. Without this, a booked
-- trip claimed by a salary run (journal_entry_id NULL, salary_run_id set)
-- could revert to draft while keeping its salary_run_id: a draft trip whose
-- allowance already sits in a run is re-bookable, i.e. double pay.
CREATE OR REPLACE FUNCTION public.enforce_booked_mileage_trip_immutability()
RETURNS trigger
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = public
AS $$
DECLARE
core_changed boolean;
BEGIN
IF OLD.status <> 'booked' THEN
RETURN NEW;
END IF;
core_changed :=
NEW.trip_date IS DISTINCT FROM OLD.trip_date
OR NEW.vehicle_type IS DISTINCT FROM OLD.vehicle_type
OR NEW.vehicle_registration IS DISTINCT FROM OLD.vehicle_registration
OR NEW.odometer_start IS DISTINCT FROM OLD.odometer_start
OR NEW.odometer_end IS DISTINCT FROM OLD.odometer_end
OR NEW.distance_km IS DISTINCT FROM OLD.distance_km
OR NEW.from_location IS DISTINCT FROM OLD.from_location
OR NEW.to_location IS DISTINCT FROM OLD.to_location
OR NEW.purpose IS DISTINCT FROM OLD.purpose
OR NEW.visited IS DISTINCT FROM OLD.visited
OR NEW.is_round_trip IS DISTINCT FROM OLD.is_round_trip
OR NEW.employee_id IS DISTINCT FROM OLD.employee_id
OR NEW.company_id IS DISTINCT FROM OLD.company_id
OR NEW.user_id IS DISTINCT FROM OLD.user_id
OR NEW.created_via IS DISTINCT FROM OLD.created_via;
IF core_changed THEN
RAISE EXCEPTION 'Cannot modify a booked mileage trip: it is retained as underlag (BFL). Reverse the verifikat first.'
USING ERRCODE = 'P0001';
END IF;
-- Revert of an unlinked claim back to draft: must detach from any salary
-- run in the same statement, so a draft trip can never keep pointing at a
-- run that already carries its allowance.
IF NEW.status = 'draft' THEN
IF OLD.journal_entry_id IS NOT NULL THEN
RAISE EXCEPTION 'Cannot unbook a mileage trip linked to a verifikat. Reverse the verifikat first.'
USING ERRCODE = 'P0001';
END IF;
IF NEW.salary_run_id IS NOT NULL THEN
RAISE EXCEPTION 'Reverting a mileage trip to draft must clear salary_run_id.'
USING ERRCODE = 'P0001';
END IF;
RETURN NEW;
END IF;
-- Booked stays booked: links may only be set from NULL, never rewritten.
IF OLD.journal_entry_id IS NOT NULL
AND NEW.journal_entry_id IS DISTINCT FROM OLD.journal_entry_id THEN
RAISE EXCEPTION 'Cannot repoint a booked mileage trip to another verifikat.'
USING ERRCODE = 'P0001';
END IF;
IF OLD.salary_run_id IS NOT NULL
AND NEW.salary_run_id IS DISTINCT FROM OLD.salary_run_id THEN
RAISE EXCEPTION 'Cannot repoint a booked mileage trip to another salary run.'
USING ERRCODE = 'P0001';
END IF;
RETURN NEW;
END;
$$;
@@ -0,0 +1,173 @@
import { describe, expect, it } from 'vitest'
import { insertPostedJournalEntry, seedCompany } from '@/tests/pg/fixtures'
import { getPool, withUserContext } from '@/tests/pg/setup'
/**
* Locks in the mileage_trips migration (20260807084705):
*
* - RLS scopes rows to the user's companies via user_company_ids();
* - a booked trip can never be deleted (körjournal is underlag, BFL 7-year
* retention: block_booked_mileage_trip_deletion trigger);
* - a draft trip can be deleted;
* - the odometer CHECK rejects an arrival reading at or below the start.
*/
async function insertTrip(params: {
companyId: string
userId: string
status?: 'draft' | 'booked'
odometerStart?: number | null
odometerEnd?: number | null
}): Promise<string> {
const res = await getPool().query<{ id: string }>(
`INSERT INTO public.mileage_trips
(company_id, user_id, trip_date, distance_km, from_location,
to_location, purpose, status, odometer_start, odometer_end)
VALUES ($1, $2, '2026-05-10', 32.3, 'Kontoret', 'Kunden', 'Kundbesök',
$3, $4, $5)
RETURNING id`,
[
params.companyId,
params.userId,
params.status ?? 'draft',
params.odometerStart ?? null,
params.odometerEnd ?? null,
],
)
return res.rows[0].id
}
describe('mileage_trips RLS', () => {
it('shows a member their company trips and hides other companies', async () => {
const a = await seedCompany()
const b = await seedCompany()
await insertTrip({ companyId: a.companyId, userId: a.userId })
await insertTrip({ companyId: b.companyId, userId: b.userId })
const visibleToA = await withUserContext(a.userId, async (client) => {
const res = await client.query(`SELECT company_id FROM public.mileage_trips`)
return res.rows
})
expect(visibleToA).toHaveLength(1)
expect(visibleToA[0].company_id).toBe(a.companyId)
})
it('blocks inserting a trip into a foreign company', async () => {
const a = await seedCompany()
const b = await seedCompany()
await expect(
withUserContext(a.userId, (client) =>
client.query(
`INSERT INTO public.mileage_trips
(company_id, user_id, trip_date, distance_km, from_location, to_location, purpose)
VALUES ($1, $2, '2026-05-10', 10, 'A', 'B', 'Test')`,
[b.companyId, a.userId],
),
),
).rejects.toThrow(/row-level security/)
})
})
describe('mileage_trips retention trigger', () => {
it('blocks deleting a booked trip (BFL underlag)', async () => {
const { companyId, userId } = await seedCompany()
const tripId = await insertTrip({ companyId, userId, status: 'booked' })
await expect(
getPool().query(`DELETE FROM public.mileage_trips WHERE id = $1`, [tripId]),
).rejects.toThrow(/booked mileage trip/)
})
it('allows deleting a draft trip', async () => {
const { companyId, userId } = await seedCompany()
const tripId = await insertTrip({ companyId, userId, status: 'draft' })
const res = await getPool().query(
`DELETE FROM public.mileage_trips WHERE id = $1`,
[tripId],
)
expect(res.rowCount).toBe(1)
})
})
describe('mileage_trips booked immutability (20260807113215)', () => {
it('blocks changing core fields on a booked trip', async () => {
const { companyId, userId } = await seedCompany()
const tripId = await insertTrip({ companyId, userId, status: 'booked' })
await expect(
getPool().query(`UPDATE public.mileage_trips SET distance_km = 999 WHERE id = $1`, [tripId]),
).rejects.toThrow(/booked mileage trip/)
})
it('allows a notes-only edit on a booked trip', async () => {
const { companyId, userId } = await seedCompany()
const tripId = await insertTrip({ companyId, userId, status: 'booked' })
const res = await getPool().query(
`UPDATE public.mileage_trips SET notes = 'anteckning' WHERE id = $1`,
[tripId],
)
expect(res.rowCount).toBe(1)
})
it('allows reverting an UNLINKED claim back to draft', async () => {
const { companyId, userId } = await seedCompany()
const tripId = await insertTrip({ companyId, userId, status: 'booked' })
const res = await getPool().query(
`UPDATE public.mileage_trips SET status = 'draft' WHERE id = $1`,
[tripId],
)
expect(res.rowCount).toBe(1)
})
it('forces a revert to draft to clear salary_run_id (20260807114924)', async () => {
const { companyId, userId } = await seedCompany()
const runRes = await getPool().query<{ id: string }>(
`INSERT INTO public.salary_runs (company_id, user_id, period_year, period_month, payment_date)
VALUES ($1, $2, 2026, 5, '2026-05-25') RETURNING id`,
[companyId, userId],
)
const runId = runRes.rows[0].id
const tripId = await insertTrip({ companyId, userId, status: 'booked' })
await getPool().query(
`UPDATE public.mileage_trips SET salary_run_id = $2 WHERE id = $1`,
[tripId, runId],
)
// Revert keeping salary_run_id: rejected (draft trip would still carry a
// run that holds its allowance = re-bookable double pay).
await expect(
getPool().query(`UPDATE public.mileage_trips SET status = 'draft' WHERE id = $1`, [tripId]),
).rejects.toThrow(/clear salary_run_id/)
// Revert clearing it in the same statement: allowed.
const res = await getPool().query(
`UPDATE public.mileage_trips SET status = 'draft', salary_run_id = NULL WHERE id = $1`,
[tripId],
)
expect(res.rowCount).toBe(1)
})
it('blocks unbooking a trip linked to a verifikat', async () => {
const { companyId, userId, fiscalPeriodId } = await seedCompany()
const entryId = await insertPostedJournalEntry({ companyId, userId, fiscalPeriodId })
const tripId = await insertTrip({ companyId, userId, status: 'booked' })
await getPool().query(
`UPDATE public.mileage_trips SET journal_entry_id = $2 WHERE id = $1`,
[tripId, entryId],
)
await expect(
getPool().query(`UPDATE public.mileage_trips SET status = 'draft' WHERE id = $1`, [tripId]),
).rejects.toThrow(/linked to a verifikat/)
})
})
describe('mileage_trips constraints', () => {
it('rejects an arrival odometer at or below the start reading', async () => {
const { companyId, userId } = await seedCompany()
await expect(
insertTrip({ companyId, userId, odometerStart: 1032, odometerEnd: 1000 }),
).rejects.toThrow(/mileage_trips_odometer_order/)
})
it('accepts a plain km distance without odometer readings', async () => {
const { companyId, userId } = await seedCompany()
const id = await insertTrip({ companyId, userId })
expect(id).toBeTruthy()
})
})