Files
accounted/supabase/migrations/20260807113215_mileage_trips_booked_immutability.sql
Mattsson 7411a0171b 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>
2026-08-07 14:17:21 +02:00

76 lines
3.0 KiB
PL/PgSQL

-- 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';