Files
accounted/app/api/mileage/trips/[id]/route.ts
T
MattssonandClaude Fable 5 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

123 lines
3.9 KiB
TypeScript

import { NextResponse } from 'next/server'
import { ensureInitialized } from '@/lib/init'
import { withRouteContext } from '@/lib/api/with-route-context'
import { validateBody } from '@/lib/api/validate'
import { UpdateMileageTripSchema } from '@/lib/api/schemas'
ensureInitialized()
type Params = { params: Promise<{ id: string }> }
/**
* A booked trip is körjournal underlag for its verifikat: immutable and
* undeletable (DB trigger backstops the delete). Only drafts can change.
*/
export const PATCH = withRouteContext<Params>(
'mileage.trips.update',
async (request, { supabase, companyId }, { params }) => {
const { id } = await params
const validation = await validateBody(request, UpdateMileageTripSchema)
if (!validation.success) return validation.response
const { data: existing } = await supabase
.from('mileage_trips')
.select('id, status, vehicle_type, vehicle_registration')
.eq('company_id', companyId)
.eq('id', id)
.maybeSingle()
if (!existing) {
return NextResponse.json({ error: 'Resan hittades inte' }, { status: 404 })
}
if (existing.status !== 'draft') {
return NextResponse.json(
{ error: 'Resan är bokförd och kan inte ändras. Makulera verifikatet först.' },
{ status: 409 }
)
}
// The employees FK is not company-scoped: verify a newly assigned
// employee belongs to this company (mirrors createTrip).
if (validation.data.employee_id) {
const { data: employee } = await supabase
.from('employees')
.select('id')
.eq('company_id', companyId)
.eq('id', validation.data.employee_id)
.maybeSingle()
if (!employee) {
return NextResponse.json(
{ error: 'Den anställda hittades inte i företaget' },
{ status: 400 }
)
}
}
// Enforce the förmånsbil regnr rule on the EFFECTIVE row (partial update
// merged over the stored values), mirroring CreateMileageTripSchema.
const effectiveVehicleType = validation.data.vehicle_type ?? existing.vehicle_type
const effectiveRegistration =
validation.data.vehicle_registration !== undefined
? validation.data.vehicle_registration
: existing.vehicle_registration
if (effectiveVehicleType !== 'own_car' && !effectiveRegistration?.trim()) {
return NextResponse.json(
{ error: 'Ange registreringsnummer för förmånsbilen' },
{ status: 400 }
)
}
const { data: updated, error } = await supabase
.from('mileage_trips')
.update(validation.data)
.eq('company_id', companyId)
.eq('id', id)
.eq('status', 'draft')
.select()
.single()
if (error || !updated) {
return NextResponse.json({ error: 'Resan kunde inte uppdateras' }, { status: 500 })
}
return NextResponse.json({ data: updated })
},
{ requireWrite: true }
)
export const DELETE = withRouteContext<Params>(
'mileage.trips.delete',
async (_request, { supabase, companyId }, { params }) => {
const { id } = await params
const { data: existing } = await supabase
.from('mileage_trips')
.select('id, status')
.eq('company_id', companyId)
.eq('id', id)
.maybeSingle()
if (!existing) {
return NextResponse.json({ error: 'Resan hittades inte' }, { status: 404 })
}
if (existing.status !== 'draft') {
return NextResponse.json(
{ error: 'Resan är bokförd och kan inte tas bort (underlag bevaras enligt bokföringslagen).' },
{ status: 409 }
)
}
const { error } = await supabase
.from('mileage_trips')
.delete()
.eq('company_id', companyId)
.eq('id', id)
.eq('status', 'draft')
if (error) {
return NextResponse.json({ error: 'Resan kunde inte tas bort' }, { status: 500 })
}
return NextResponse.json({ data: { deleted: true } })
},
{ requireWrite: true }
)