* 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>
174 lines
6.6 KiB
TypeScript
174 lines
6.6 KiB
TypeScript
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()
|
|
})
|
|
})
|