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:
co-authored by
Claude Fable 5
parent
f7f3a31f8e
commit
7411a0171b
@@ -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,
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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 }
|
||||
)
|
||||
@@ -0,0 +1,63 @@
|
||||
import { NextResponse } from 'next/server'
|
||||
import { ensureInitialized } from '@/lib/init'
|
||||
import { withRouteContext } from '@/lib/api/with-route-context'
|
||||
import { ISO_DATE_RE } from '@/lib/invariants'
|
||||
import { listTrips } from '@/lib/mileage/mileage-service'
|
||||
import { mileageTripsToCsv } from '@/lib/mileage/csv-export'
|
||||
|
||||
ensureInitialized()
|
||||
|
||||
/** Körjournal CSV export (Skatteverket audit underlag). */
|
||||
export const GET = withRouteContext('mileage.export', async (request, { supabase, companyId }) => {
|
||||
const { searchParams } = new URL(request.url)
|
||||
const from = searchParams.get('from') || undefined
|
||||
const to = searchParams.get('to') || undefined
|
||||
// Validated dates are also what the Content-Disposition filename is built
|
||||
// from, so nothing user-controlled reaches the header unchecked.
|
||||
for (const value of [from, to]) {
|
||||
if (value !== undefined && !ISO_DATE_RE.test(value)) {
|
||||
return NextResponse.json({ error: 'Ogiltigt datum (ÅÅÅÅ-MM-DD)' }, { status: 400 })
|
||||
}
|
||||
}
|
||||
|
||||
const trips = await listTrips(supabase, companyId, { from, to })
|
||||
// Oldest first in the export: a körjournal reads chronologically.
|
||||
trips.reverse()
|
||||
|
||||
const entryIds = [...new Set(trips.map((t) => t.journal_entry_id).filter(Boolean))] as string[]
|
||||
const voucherLabels = new Map<string, string>()
|
||||
if (entryIds.length > 0) {
|
||||
const { data: entries } = await supabase
|
||||
.from('journal_entries')
|
||||
.select('id, voucher_series, voucher_number')
|
||||
.eq('company_id', companyId)
|
||||
.in('id', entryIds)
|
||||
for (const entry of entries || []) {
|
||||
voucherLabels.set(entry.id, `${entry.voucher_series}${entry.voucher_number}`)
|
||||
}
|
||||
}
|
||||
|
||||
// Driver attribution (BFL 5 kap 6-7 §): name the employee per trip so a
|
||||
// multi-employee körjournal stays attributable in the exported underlag.
|
||||
const employeeIds = [...new Set(trips.map((t) => t.employee_id).filter(Boolean))] as string[]
|
||||
const driverLabels = new Map<string, string>()
|
||||
if (employeeIds.length > 0) {
|
||||
const { data: employees } = await supabase
|
||||
.from('employees')
|
||||
.select('id, first_name, last_name')
|
||||
.eq('company_id', companyId)
|
||||
.in('id', employeeIds)
|
||||
for (const employee of employees || []) {
|
||||
driverLabels.set(employee.id, `${employee.first_name} ${employee.last_name}`)
|
||||
}
|
||||
}
|
||||
|
||||
const csv = mileageTripsToCsv(trips, voucherLabels, driverLabels)
|
||||
const suffix = [from, to].filter(Boolean).join('_')
|
||||
return new NextResponse(csv, {
|
||||
headers: {
|
||||
'Content-Type': 'text/csv; charset=utf-8',
|
||||
'Content-Disposition': `attachment; filename="korjournal${suffix ? `_${suffix}` : ''}.csv"`,
|
||||
},
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,61 @@
|
||||
import { NextResponse } from 'next/server'
|
||||
import { ensureInitialized } from '@/lib/init'
|
||||
import { withRouteContext } from '@/lib/api/with-route-context'
|
||||
import { validateBody } from '@/lib/api/validate'
|
||||
import { MileageSalaryPushSchema } from '@/lib/api/schemas'
|
||||
import { pushMileageToSalaryRun } from '@/lib/mileage/mileage-service'
|
||||
|
||||
ensureInitialized()
|
||||
|
||||
export const POST = withRouteContext(
|
||||
'mileage.salary_push',
|
||||
async (request, { supabase, companyId }) => {
|
||||
const validation = await validateBody(request, MileageSalaryPushSchema)
|
||||
if (!validation.success) return validation.response
|
||||
const body = validation.data
|
||||
|
||||
const result = await pushMileageToSalaryRun(supabase, companyId, {
|
||||
runId: body.run_id,
|
||||
employeeId: body.employee_id,
|
||||
from: body.from,
|
||||
to: body.to,
|
||||
includeUnassigned: body.include_unassigned,
|
||||
})
|
||||
|
||||
if (!result.ok) {
|
||||
switch (result.code) {
|
||||
case 'RUN_NOT_FOUND':
|
||||
return NextResponse.json({ error: 'Lönekörningen hittades inte' }, { status: 404 })
|
||||
case 'EMPLOYEE_NOT_IN_RUN':
|
||||
return NextResponse.json(
|
||||
{ error: 'Den anställda ingår inte i lönekörningen' },
|
||||
{ status: 404 }
|
||||
)
|
||||
case 'RUN_NOT_EDITABLE':
|
||||
return NextResponse.json(
|
||||
{ error: 'Lönekörningen kan inte längre ändras' },
|
||||
{ status: 409 }
|
||||
)
|
||||
case 'NO_TRIPS':
|
||||
return NextResponse.json(
|
||||
{ error: 'Inga obokförda resor i den valda perioden' },
|
||||
{ status: 400 }
|
||||
)
|
||||
case 'CLAIM_LOST':
|
||||
return NextResponse.json(
|
||||
{ error: 'Körjournalen ändrades samtidigt av en annan bokning. Ladda om och försök igen.' },
|
||||
{ status: 409 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
data: {
|
||||
trip_count: result.tripCount,
|
||||
total_amount: result.totalAmount,
|
||||
summaries: result.summaries,
|
||||
},
|
||||
})
|
||||
},
|
||||
{ requireWrite: true }
|
||||
)
|
||||
@@ -0,0 +1,160 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { NextResponse } from 'next/server'
|
||||
|
||||
vi.mock('@/lib/init', () => ({ ensureInitialized: vi.fn() }))
|
||||
vi.mock('@/lib/auth/require-auth', () => ({ requireAuth: vi.fn() }))
|
||||
vi.mock('@/lib/company/context', () => ({
|
||||
getActiveCompanyId: vi.fn().mockResolvedValue('company-1'),
|
||||
requireCompanyId: vi.fn().mockResolvedValue('company-1'),
|
||||
}))
|
||||
vi.mock('@/lib/auth/require-write', () => ({
|
||||
requireWritePermission: vi.fn().mockResolvedValue({ ok: true }),
|
||||
}))
|
||||
|
||||
import { PATCH, DELETE } from '../route'
|
||||
import { requireAuth } from '@/lib/auth/require-auth'
|
||||
|
||||
const params = { params: Promise.resolve({ id: 'trip-1' }) } as never
|
||||
|
||||
type ExistingRow = {
|
||||
id: string
|
||||
status: string
|
||||
vehicle_type: string
|
||||
vehicle_registration: string | null
|
||||
} | null
|
||||
|
||||
function supabaseWith(existing: ExistingRow, updated: unknown = { id: 'trip-1' }) {
|
||||
const chain: Record<string, unknown> = {}
|
||||
for (const method of ['select', 'eq', 'update', 'delete', 'in']) {
|
||||
chain[method] = vi.fn(() => chain)
|
||||
}
|
||||
chain.maybeSingle = vi.fn(() => Promise.resolve({ data: existing, error: null }))
|
||||
chain.single = vi.fn(() => Promise.resolve({ data: updated, error: null }))
|
||||
chain.then = (resolve: (v: unknown) => unknown) =>
|
||||
Promise.resolve({ data: null, error: null }).then(resolve)
|
||||
return { from: vi.fn(() => chain), chain }
|
||||
}
|
||||
|
||||
function authed(supabase: unknown) {
|
||||
vi.mocked(requireAuth).mockResolvedValue({
|
||||
user: { id: 'user-1' } as never,
|
||||
supabase: supabase as never,
|
||||
error: null,
|
||||
} as never)
|
||||
}
|
||||
|
||||
function patchReq(body: unknown) {
|
||||
return new Request('https://x.test/api/mileage/trips/trip-1', {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
})
|
||||
}
|
||||
|
||||
const DRAFT_OWN_CAR: ExistingRow = {
|
||||
id: 'trip-1',
|
||||
status: 'draft',
|
||||
vehicle_type: 'own_car',
|
||||
vehicle_registration: null,
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
describe('PATCH /api/mileage/trips/[id]', () => {
|
||||
it('returns 401 when unauthenticated', async () => {
|
||||
vi.mocked(requireAuth).mockResolvedValue({
|
||||
user: null,
|
||||
supabase: null,
|
||||
error: NextResponse.json({ error: 'Unauthorized' }, { status: 401 }),
|
||||
} as never)
|
||||
const res = await PATCH(patchReq({ purpose: 'Nytt ärende' }), params)
|
||||
expect(res.status).toBe(401)
|
||||
})
|
||||
|
||||
it('returns 404 for a trip outside the company', async () => {
|
||||
authed(supabaseWith(null))
|
||||
const res = await PATCH(patchReq({ purpose: 'Nytt ärende' }), params)
|
||||
expect(res.status).toBe(404)
|
||||
})
|
||||
|
||||
it('returns 409 for a booked trip (underlag is immutable)', async () => {
|
||||
authed(
|
||||
supabaseWith({ ...DRAFT_OWN_CAR, status: 'booked' })
|
||||
)
|
||||
const res = await PATCH(patchReq({ purpose: 'Nytt ärende' }), params)
|
||||
expect(res.status).toBe(409)
|
||||
})
|
||||
|
||||
it('rejects assigning an employee outside the company', async () => {
|
||||
// maybeSingle serves the trip lookup first, then the employee lookup: the
|
||||
// second call finds no company-scoped employee row.
|
||||
const supabase = supabaseWith(DRAFT_OWN_CAR)
|
||||
const maybeSingle = supabase.chain.maybeSingle as ReturnType<typeof vi.fn>
|
||||
maybeSingle
|
||||
.mockImplementationOnce(() => Promise.resolve({ data: DRAFT_OWN_CAR, error: null }))
|
||||
.mockImplementationOnce(() => Promise.resolve({ data: null, error: null }))
|
||||
authed(supabase)
|
||||
const res = await PATCH(
|
||||
patchReq({ employee_id: '9f8e7d6c-5b4a-4321-8abc-def012345678' }),
|
||||
params
|
||||
)
|
||||
expect(res.status).toBe(400)
|
||||
const body = await res.json()
|
||||
expect(body.error).toContain('anställda')
|
||||
})
|
||||
|
||||
it('rejects switching to förmånsbil when neither patch nor row has a regnr', async () => {
|
||||
authed(supabaseWith(DRAFT_OWN_CAR))
|
||||
const res = await PATCH(patchReq({ vehicle_type: 'company_car_fossil' }), params)
|
||||
expect(res.status).toBe(400)
|
||||
})
|
||||
|
||||
it('allows switching to förmånsbil when the stored row already has a regnr', async () => {
|
||||
authed(
|
||||
supabaseWith({ ...DRAFT_OWN_CAR, vehicle_registration: 'ABC123' })
|
||||
)
|
||||
const res = await PATCH(patchReq({ vehicle_type: 'company_car_fossil' }), params)
|
||||
expect(res.status).toBe(200)
|
||||
})
|
||||
|
||||
it('updates a draft trip', async () => {
|
||||
authed(supabaseWith(DRAFT_OWN_CAR, { id: 'trip-1', purpose: 'Nytt ärende' }))
|
||||
const res = await PATCH(patchReq({ purpose: 'Nytt ärende' }), params)
|
||||
expect(res.status).toBe(200)
|
||||
const body = await res.json()
|
||||
expect(body.data.purpose).toBe('Nytt ärende')
|
||||
})
|
||||
})
|
||||
|
||||
describe('DELETE /api/mileage/trips/[id]', () => {
|
||||
it('returns 404 for a trip outside the company', async () => {
|
||||
authed(supabaseWith(null))
|
||||
const res = await DELETE(
|
||||
new Request('https://x.test/api/mileage/trips/trip-1', { method: 'DELETE' }),
|
||||
params
|
||||
)
|
||||
expect(res.status).toBe(404)
|
||||
})
|
||||
|
||||
it('returns 409 for a booked trip (BFL retention)', async () => {
|
||||
authed(supabaseWith({ ...DRAFT_OWN_CAR, status: 'booked' }))
|
||||
const res = await DELETE(
|
||||
new Request('https://x.test/api/mileage/trips/trip-1', { method: 'DELETE' }),
|
||||
params
|
||||
)
|
||||
expect(res.status).toBe(409)
|
||||
})
|
||||
|
||||
it('deletes a draft trip', async () => {
|
||||
authed(supabaseWith(DRAFT_OWN_CAR))
|
||||
const res = await DELETE(
|
||||
new Request('https://x.test/api/mileage/trips/trip-1', { method: 'DELETE' }),
|
||||
params
|
||||
)
|
||||
expect(res.status).toBe(200)
|
||||
const body = await res.json()
|
||||
expect(body.data.deleted).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,122 @@
|
||||
import { NextResponse } from 'next/server'
|
||||
import { ensureInitialized } from '@/lib/init'
|
||||
import { withRouteContext } from '@/lib/api/with-route-context'
|
||||
import { validateBody } from '@/lib/api/validate'
|
||||
import { UpdateMileageTripSchema } from '@/lib/api/schemas'
|
||||
|
||||
ensureInitialized()
|
||||
|
||||
type Params = { params: Promise<{ id: string }> }
|
||||
|
||||
/**
|
||||
* A booked trip is körjournal underlag for its verifikat: immutable and
|
||||
* undeletable (DB trigger backstops the delete). Only drafts can change.
|
||||
*/
|
||||
export const PATCH = withRouteContext<Params>(
|
||||
'mileage.trips.update',
|
||||
async (request, { supabase, companyId }, { params }) => {
|
||||
const { id } = await params
|
||||
const validation = await validateBody(request, UpdateMileageTripSchema)
|
||||
if (!validation.success) return validation.response
|
||||
|
||||
const { data: existing } = await supabase
|
||||
.from('mileage_trips')
|
||||
.select('id, status, vehicle_type, vehicle_registration')
|
||||
.eq('company_id', companyId)
|
||||
.eq('id', id)
|
||||
.maybeSingle()
|
||||
|
||||
if (!existing) {
|
||||
return NextResponse.json({ error: 'Resan hittades inte' }, { status: 404 })
|
||||
}
|
||||
if (existing.status !== 'draft') {
|
||||
return NextResponse.json(
|
||||
{ error: 'Resan är bokförd och kan inte ändras. Makulera verifikatet först.' },
|
||||
{ status: 409 }
|
||||
)
|
||||
}
|
||||
|
||||
// The employees FK is not company-scoped: verify a newly assigned
|
||||
// employee belongs to this company (mirrors createTrip).
|
||||
if (validation.data.employee_id) {
|
||||
const { data: employee } = await supabase
|
||||
.from('employees')
|
||||
.select('id')
|
||||
.eq('company_id', companyId)
|
||||
.eq('id', validation.data.employee_id)
|
||||
.maybeSingle()
|
||||
if (!employee) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Den anställda hittades inte i företaget' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Enforce the förmånsbil regnr rule on the EFFECTIVE row (partial update
|
||||
// merged over the stored values), mirroring CreateMileageTripSchema.
|
||||
const effectiveVehicleType = validation.data.vehicle_type ?? existing.vehicle_type
|
||||
const effectiveRegistration =
|
||||
validation.data.vehicle_registration !== undefined
|
||||
? validation.data.vehicle_registration
|
||||
: existing.vehicle_registration
|
||||
if (effectiveVehicleType !== 'own_car' && !effectiveRegistration?.trim()) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Ange registreringsnummer för förmånsbilen' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
const { data: updated, error } = await supabase
|
||||
.from('mileage_trips')
|
||||
.update(validation.data)
|
||||
.eq('company_id', companyId)
|
||||
.eq('id', id)
|
||||
.eq('status', 'draft')
|
||||
.select()
|
||||
.single()
|
||||
|
||||
if (error || !updated) {
|
||||
return NextResponse.json({ error: 'Resan kunde inte uppdateras' }, { status: 500 })
|
||||
}
|
||||
return NextResponse.json({ data: updated })
|
||||
},
|
||||
{ requireWrite: true }
|
||||
)
|
||||
|
||||
export const DELETE = withRouteContext<Params>(
|
||||
'mileage.trips.delete',
|
||||
async (_request, { supabase, companyId }, { params }) => {
|
||||
const { id } = await params
|
||||
|
||||
const { data: existing } = await supabase
|
||||
.from('mileage_trips')
|
||||
.select('id, status')
|
||||
.eq('company_id', companyId)
|
||||
.eq('id', id)
|
||||
.maybeSingle()
|
||||
|
||||
if (!existing) {
|
||||
return NextResponse.json({ error: 'Resan hittades inte' }, { status: 404 })
|
||||
}
|
||||
if (existing.status !== 'draft') {
|
||||
return NextResponse.json(
|
||||
{ error: 'Resan är bokförd och kan inte tas bort (underlag bevaras enligt bokföringslagen).' },
|
||||
{ status: 409 }
|
||||
)
|
||||
}
|
||||
|
||||
const { error } = await supabase
|
||||
.from('mileage_trips')
|
||||
.delete()
|
||||
.eq('company_id', companyId)
|
||||
.eq('id', id)
|
||||
.eq('status', 'draft')
|
||||
|
||||
if (error) {
|
||||
return NextResponse.json({ error: 'Resan kunde inte tas bort' }, { status: 500 })
|
||||
}
|
||||
return NextResponse.json({ data: { deleted: true } })
|
||||
},
|
||||
{ requireWrite: true }
|
||||
)
|
||||
@@ -0,0 +1,131 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { NextResponse } from 'next/server'
|
||||
|
||||
vi.mock('@/lib/init', () => ({ ensureInitialized: vi.fn() }))
|
||||
vi.mock('@/lib/auth/require-auth', () => ({ requireAuth: vi.fn() }))
|
||||
vi.mock('@/lib/company/context', () => ({
|
||||
getActiveCompanyId: vi.fn().mockResolvedValue('company-1'),
|
||||
requireCompanyId: vi.fn().mockResolvedValue('company-1'),
|
||||
}))
|
||||
vi.mock('@/lib/auth/require-write', () => ({
|
||||
requireWritePermission: vi.fn().mockResolvedValue({ ok: true }),
|
||||
}))
|
||||
vi.mock('@/lib/mileage/mileage-service', () => ({
|
||||
listTrips: vi.fn(),
|
||||
createTrip: vi.fn(),
|
||||
}))
|
||||
|
||||
import { GET, POST } from '../route'
|
||||
import { requireAuth } from '@/lib/auth/require-auth'
|
||||
import { createTrip, listTrips } from '@/lib/mileage/mileage-service'
|
||||
|
||||
const params = { params: Promise.resolve({}) } as never
|
||||
|
||||
function authed() {
|
||||
vi.mocked(requireAuth).mockResolvedValue({
|
||||
user: { id: 'user-1' } as never,
|
||||
supabase: {} as never,
|
||||
error: null,
|
||||
} as never)
|
||||
}
|
||||
|
||||
function unauthed() {
|
||||
vi.mocked(requireAuth).mockResolvedValue({
|
||||
user: null,
|
||||
supabase: null,
|
||||
error: NextResponse.json({ error: 'Unauthorized' }, { status: 401 }),
|
||||
} as never)
|
||||
}
|
||||
|
||||
const VALID_TRIP = {
|
||||
trip_date: '2026-05-10',
|
||||
distance_km: 32,
|
||||
from_location: 'Kontoret',
|
||||
to_location: 'Kunden',
|
||||
purpose: 'Kundbesök',
|
||||
}
|
||||
|
||||
function postReq(body: unknown) {
|
||||
return new Request('https://x.test/api/mileage/trips', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
})
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
describe('GET /api/mileage/trips', () => {
|
||||
it('returns 401 when unauthenticated', async () => {
|
||||
unauthed()
|
||||
const res = await GET(new Request('https://x.test/api/mileage/trips'), params)
|
||||
expect(res.status).toBe(401)
|
||||
})
|
||||
|
||||
it('returns the trip list and forwards filters', async () => {
|
||||
authed()
|
||||
vi.mocked(listTrips).mockResolvedValue([{ id: 't1' }] as never)
|
||||
const res = await GET(
|
||||
new Request('https://x.test/api/mileage/trips?from=2026-05-01&to=2026-05-31&status=draft'),
|
||||
params
|
||||
)
|
||||
expect(res.status).toBe(200)
|
||||
const body = await res.json()
|
||||
expect(body.data).toHaveLength(1)
|
||||
expect(vi.mocked(listTrips).mock.calls[0][2]).toMatchObject({
|
||||
from: '2026-05-01',
|
||||
to: '2026-05-31',
|
||||
status: 'draft',
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('POST /api/mileage/trips', () => {
|
||||
it('returns 401 when unauthenticated', async () => {
|
||||
unauthed()
|
||||
const res = await POST(postReq(VALID_TRIP), params)
|
||||
expect(res.status).toBe(401)
|
||||
})
|
||||
|
||||
it('returns 400 on invalid body (missing purpose, negative km)', async () => {
|
||||
authed()
|
||||
const res = await POST(
|
||||
postReq({ trip_date: '2026-05-10', distance_km: -5, from_location: 'A', to_location: 'B' }),
|
||||
params
|
||||
)
|
||||
expect(res.status).toBe(400)
|
||||
expect(createTrip).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('rejects a förmånsbil trip without vehicle_registration', async () => {
|
||||
authed()
|
||||
const res = await POST(
|
||||
postReq({ ...VALID_TRIP, vehicle_type: 'company_car_fossil' }),
|
||||
params
|
||||
)
|
||||
expect(res.status).toBe(400)
|
||||
expect(createTrip).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('rejects odometer_end <= odometer_start', async () => {
|
||||
authed()
|
||||
const res = await POST(
|
||||
postReq({ ...VALID_TRIP, odometer_start: 1032, odometer_end: 1000 }),
|
||||
params
|
||||
)
|
||||
expect(res.status).toBe(400)
|
||||
})
|
||||
|
||||
it('creates the trip and returns 201', async () => {
|
||||
authed()
|
||||
vi.mocked(createTrip).mockResolvedValue({ id: 't1', status: 'draft' } as never)
|
||||
const res = await POST(postReq(VALID_TRIP), params)
|
||||
expect(res.status).toBe(201)
|
||||
const body = await res.json()
|
||||
expect(body.data.id).toBe('t1')
|
||||
expect(vi.mocked(createTrip).mock.calls[0][1]).toBe('company-1')
|
||||
expect(vi.mocked(createTrip).mock.calls[0][2]).toBe('user-1')
|
||||
})
|
||||
})
|
||||
@@ -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 }
|
||||
)
|
||||
Reference in New Issue
Block a user