* 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>
450 lines
16 KiB
TypeScript
450 lines
16 KiB
TypeScript
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
|
import type { PayrollConfig } from '@/lib/salary/payroll-config'
|
|
import type { MileageTrip } from '@/types'
|
|
|
|
vi.mock('@/lib/supabase/fetch-all', () => ({ fetchAllRows: vi.fn() }))
|
|
vi.mock('@/lib/bookkeeping/engine', async (importOriginal) => {
|
|
const actual = await importOriginal<typeof import('@/lib/bookkeeping/engine')>()
|
|
return { ...actual, createJournalEntry: vi.fn() }
|
|
})
|
|
vi.mock('@/lib/salary/payroll-config', async (importOriginal) => {
|
|
const actual = await importOriginal<typeof import('@/lib/salary/payroll-config')>()
|
|
return { ...actual, loadPayrollConfig: vi.fn() }
|
|
})
|
|
vi.mock('@/lib/core/bookkeeping/period-service', () => ({
|
|
resolvePeriodStatusForDate: vi.fn(),
|
|
}))
|
|
|
|
import {
|
|
bookMileagePeriod,
|
|
createTrip,
|
|
pushMileageToSalaryRun,
|
|
ratePerMil,
|
|
summarizeTrips,
|
|
} from '@/lib/mileage/mileage-service'
|
|
import { fetchAllRows } from '@/lib/supabase/fetch-all'
|
|
import { createJournalEntry } from '@/lib/bookkeeping/engine'
|
|
import { loadPayrollConfig } from '@/lib/salary/payroll-config'
|
|
import { resolvePeriodStatusForDate } from '@/lib/core/bookkeeping/period-service'
|
|
|
|
const CONFIG = {
|
|
milersattningEgenBil: 25,
|
|
milersattningFormansbilFossil: 12,
|
|
milersattningFormansbilEl: 9.5,
|
|
} as PayrollConfig
|
|
|
|
function trip(overrides: Partial<MileageTrip>): MileageTrip {
|
|
return {
|
|
id: 'trip-1',
|
|
company_id: 'company-1',
|
|
user_id: 'user-1',
|
|
employee_id: null,
|
|
trip_date: '2026-05-10',
|
|
vehicle_type: 'own_car',
|
|
vehicle_registration: null,
|
|
odometer_start: null,
|
|
odometer_end: null,
|
|
distance_km: 100,
|
|
from_location: 'Kontoret',
|
|
to_location: 'Kunden',
|
|
purpose: 'Kundbesök',
|
|
visited: null,
|
|
is_round_trip: false,
|
|
status: 'draft',
|
|
journal_entry_id: null,
|
|
salary_run_id: null,
|
|
notes: null,
|
|
created_via: 'manual',
|
|
created_at: '2026-05-10T00:00:00Z',
|
|
updated_at: '2026-05-10T00:00:00Z',
|
|
...overrides,
|
|
}
|
|
}
|
|
|
|
beforeEach(() => {
|
|
vi.clearAllMocks()
|
|
})
|
|
|
|
describe('ratePerMil', () => {
|
|
it('maps every vehicle type to its config rate', () => {
|
|
expect(ratePerMil(CONFIG, 'own_car')).toBe(25)
|
|
expect(ratePerMil(CONFIG, 'company_car_fossil')).toBe(12)
|
|
expect(ratePerMil(CONFIG, 'company_car_electric')).toBe(9.5)
|
|
})
|
|
})
|
|
|
|
describe('summarizeTrips', () => {
|
|
it('converts km to mil and applies the schablon rate', () => {
|
|
const [summary] = summarizeTrips([trip({ distance_km: 100 })], CONFIG)
|
|
expect(summary).toMatchObject({
|
|
vehicle_type: 'own_car',
|
|
trip_count: 1,
|
|
total_km: 100,
|
|
total_mil: 10,
|
|
rate_per_mil: 25,
|
|
amount: 250,
|
|
})
|
|
})
|
|
|
|
it('keeps öre precision without drift (32.3 km → 80.75 kr)', () => {
|
|
const [summary] = summarizeTrips([trip({ distance_km: 32.3 })], CONFIG)
|
|
expect(summary.total_mil).toBe(3.23)
|
|
expect(summary.amount).toBe(80.75)
|
|
})
|
|
|
|
it('groups by vehicle type and sums per group', () => {
|
|
const summaries = summarizeTrips(
|
|
[
|
|
trip({ distance_km: 40 }),
|
|
trip({ distance_km: 60 }),
|
|
trip({ distance_km: 50, vehicle_type: 'company_car_electric' }),
|
|
],
|
|
CONFIG
|
|
)
|
|
expect(summaries).toHaveLength(2)
|
|
const own = summaries.find((s) => s.vehicle_type === 'own_car')
|
|
const el = summaries.find((s) => s.vehicle_type === 'company_car_electric')
|
|
expect(own).toMatchObject({ trip_count: 2, total_km: 100, amount: 250 })
|
|
expect(el).toMatchObject({ trip_count: 1, total_km: 50, amount: 47.5 })
|
|
})
|
|
|
|
it('tolerates numeric-as-string distances from Postgres', () => {
|
|
const [summary] = summarizeTrips(
|
|
[trip({ distance_km: '12.5' as unknown as number })],
|
|
CONFIG
|
|
)
|
|
expect(summary.total_km).toBe(12.5)
|
|
expect(summary.amount).toBe(31.25)
|
|
})
|
|
})
|
|
|
|
describe('createTrip', () => {
|
|
it('rejects a förmånsbil trip without vehicle_registration before any write', async () => {
|
|
const supabase = { from: vi.fn() }
|
|
await expect(
|
|
createTrip(supabase as never, 'company-1', 'user-1', {
|
|
trip_date: '2026-05-10',
|
|
vehicle_type: 'company_car_electric',
|
|
distance_km: 10,
|
|
from_location: 'A',
|
|
to_location: 'B',
|
|
purpose: 'Kundbesök',
|
|
})
|
|
).rejects.toThrow(/registreringsnummer/)
|
|
expect(supabase.from).not.toHaveBeenCalled()
|
|
})
|
|
})
|
|
|
|
describe('pushMileageToSalaryRun', () => {
|
|
const params = {
|
|
runId: 'run-1',
|
|
employeeId: 'emp-1',
|
|
from: '2026-05-01',
|
|
to: '2026-05-31',
|
|
}
|
|
|
|
function salarySupabase(opts: {
|
|
run?: { id: string; status: string } | null
|
|
sre?: { id: string } | null
|
|
claimIds?: string[]
|
|
itemError?: { message: string } | null
|
|
}) {
|
|
const insert = vi.fn(() => Promise.resolve({ error: opts.itemError ?? null }))
|
|
const tripChain: Record<string, unknown> = {}
|
|
for (const method of ['update', 'eq', 'in', 'is']) {
|
|
tripChain[method] = vi.fn(() => tripChain)
|
|
}
|
|
tripChain.select = vi.fn(() =>
|
|
Promise.resolve({ data: (opts.claimIds ?? []).map((id) => ({ id })), error: null })
|
|
)
|
|
tripChain.then = (resolve: (v: unknown) => unknown) =>
|
|
Promise.resolve({ data: null, error: null }).then(resolve)
|
|
|
|
const singleChain = (row: unknown) => {
|
|
const chain: Record<string, unknown> = {}
|
|
for (const method of ['select', 'eq']) {
|
|
chain[method] = vi.fn(() => chain)
|
|
}
|
|
chain.single = vi.fn(() => Promise.resolve({ data: row, error: row ? null : {} }))
|
|
return chain
|
|
}
|
|
|
|
return {
|
|
from: vi.fn((table: string) => {
|
|
if (table === 'salary_runs') return singleChain(opts.run ?? null)
|
|
if (table === 'salary_run_employees') return singleChain(opts.sre ?? null)
|
|
if (table === 'salary_line_items') return { insert }
|
|
return tripChain
|
|
}),
|
|
insert,
|
|
tripChain,
|
|
}
|
|
}
|
|
|
|
it('maps missing run / wrong status / missing employee to their codes', async () => {
|
|
const missingRun = salarySupabase({ run: null })
|
|
expect(
|
|
await pushMileageToSalaryRun(missingRun as never, 'company-1', params)
|
|
).toEqual({ ok: false, code: 'RUN_NOT_FOUND' })
|
|
|
|
const bookedRun = salarySupabase({ run: { id: 'run-1', status: 'booked' } })
|
|
expect(
|
|
await pushMileageToSalaryRun(bookedRun as never, 'company-1', params)
|
|
).toEqual({ ok: false, code: 'RUN_NOT_EDITABLE' })
|
|
|
|
const noSre = salarySupabase({ run: { id: 'run-1', status: 'draft' }, sre: null })
|
|
expect(
|
|
await pushMileageToSalaryRun(noSre as never, 'company-1', params)
|
|
).toEqual({ ok: false, code: 'EMPLOYEE_NOT_IN_RUN' })
|
|
})
|
|
|
|
it('claims trips BEFORE inserting line items and inserts kostnadsersättning flags', async () => {
|
|
vi.mocked(fetchAllRows).mockResolvedValue([
|
|
trip({ id: 't1', employee_id: 'emp-1', distance_km: 100 }),
|
|
])
|
|
vi.mocked(loadPayrollConfig).mockResolvedValue(CONFIG)
|
|
const supabase = salarySupabase({
|
|
run: { id: 'run-1', status: 'draft' },
|
|
sre: { id: 'sre-1' },
|
|
claimIds: ['t1'],
|
|
})
|
|
|
|
const result = await pushMileageToSalaryRun(supabase as never, 'company-1', params)
|
|
expect(result).toMatchObject({ ok: true, tripCount: 1, totalAmount: 250 })
|
|
const item = (supabase.insert.mock.calls[0] as unknown[][])[0][0]
|
|
expect(item).toMatchObject({
|
|
item_type: 'mileage_taxfree',
|
|
amount: 250,
|
|
is_taxable: false,
|
|
is_avgift_basis: false,
|
|
is_vacation_basis: false,
|
|
account_number: '7331',
|
|
})
|
|
// The claim ran before the insert (retry cannot double-pay).
|
|
const claimOrder = (supabase.tripChain.update as ReturnType<typeof vi.fn>).mock
|
|
.invocationCallOrder[0]
|
|
const insertOrder = supabase.insert.mock.invocationCallOrder[0]
|
|
expect(claimOrder).toBeLessThan(insertOrder)
|
|
})
|
|
|
|
it('returns CLAIM_LOST and reverts when another booking claimed first', async () => {
|
|
vi.mocked(fetchAllRows).mockResolvedValue([
|
|
trip({ id: 't1', employee_id: 'emp-1' }),
|
|
trip({ id: 't2', employee_id: 'emp-1' }),
|
|
])
|
|
vi.mocked(loadPayrollConfig).mockResolvedValue(CONFIG)
|
|
const supabase = salarySupabase({
|
|
run: { id: 'run-1', status: 'draft' },
|
|
sre: { id: 'sre-1' },
|
|
claimIds: ['t1'],
|
|
})
|
|
const result = await pushMileageToSalaryRun(supabase as never, 'company-1', params)
|
|
expect(result).toEqual({ ok: false, code: 'CLAIM_LOST' })
|
|
expect(supabase.insert).not.toHaveBeenCalled()
|
|
// The partial claim was reverted, not left dangling.
|
|
expect(supabase.tripChain.update).toHaveBeenCalledWith({
|
|
status: 'draft',
|
|
salary_run_id: null,
|
|
})
|
|
})
|
|
|
|
it('reverts the claim when the line item insert fails', async () => {
|
|
vi.mocked(fetchAllRows).mockResolvedValue([trip({ id: 't1', employee_id: 'emp-1' })])
|
|
vi.mocked(loadPayrollConfig).mockResolvedValue(CONFIG)
|
|
const supabase = salarySupabase({
|
|
run: { id: 'run-1', status: 'draft' },
|
|
sre: { id: 'sre-1' },
|
|
claimIds: ['t1'],
|
|
itemError: { message: 'insert failed' },
|
|
})
|
|
await expect(
|
|
pushMileageToSalaryRun(supabase as never, 'company-1', params)
|
|
).rejects.toThrow('insert failed')
|
|
expect(supabase.tripChain.update).toHaveBeenCalledWith({ status: 'draft', salary_run_id: null })
|
|
})
|
|
})
|
|
|
|
describe('bookMileagePeriod', () => {
|
|
const params = {
|
|
from: '2026-05-01',
|
|
to: '2026-05-31',
|
|
entryDate: '2026-05-31',
|
|
counterAccount: '2820' as const,
|
|
}
|
|
|
|
// Queued mock: each .select() call consumes the next result (claim first,
|
|
// then the journal_entry_id link). The orphan sweep and revert paths await
|
|
// the chain without .select(), so the chain itself is thenable.
|
|
function stampSupabase(selectResults: string[][]) {
|
|
const queue = [...selectResults]
|
|
const chain: Record<string, unknown> = {}
|
|
for (const method of ['update', 'eq', 'in', 'is', 'lt', 'gte', 'lte', 'maybeSingle']) {
|
|
chain[method] = vi.fn(() => chain)
|
|
}
|
|
chain.select = vi.fn(() => {
|
|
const ids = queue.shift() ?? []
|
|
return Promise.resolve({ data: ids.map((id) => ({ id })), error: null })
|
|
})
|
|
chain.then = (resolve: (v: unknown) => unknown) =>
|
|
Promise.resolve({ data: null, error: null }).then(resolve)
|
|
return { from: vi.fn(() => chain), chain }
|
|
}
|
|
|
|
it('returns NO_TRIPS when the period has no drafts', async () => {
|
|
vi.mocked(fetchAllRows).mockResolvedValue([])
|
|
const result = await bookMileagePeriod(
|
|
stampSupabase([]) as never,
|
|
'company-1',
|
|
'user-1',
|
|
params
|
|
)
|
|
expect(result).toEqual({ ok: false, code: 'NO_TRIPS' })
|
|
expect(createJournalEntry).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('loses a concurrent race cleanly: partial claim reverts as CLAIM_LOST', async () => {
|
|
vi.mocked(fetchAllRows).mockResolvedValue([trip({ id: 't1' }), trip({ id: 't2' })])
|
|
vi.mocked(resolvePeriodStatusForDate).mockResolvedValue({
|
|
status: 'open',
|
|
period_id: 'period-1',
|
|
} as never)
|
|
vi.mocked(loadPayrollConfig).mockResolvedValue(CONFIG)
|
|
|
|
// Another booking claimed t2 first: our claim only gets t1.
|
|
const supabase = stampSupabase([['t1']])
|
|
const result = await bookMileagePeriod(supabase as never, 'company-1', 'user-1', params)
|
|
expect(result).toEqual({ ok: false, code: 'CLAIM_LOST' })
|
|
expect(createJournalEntry).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('fails as TRIPS_CHANGED when the staged trip set drifted before approval', async () => {
|
|
vi.mocked(fetchAllRows).mockResolvedValue([trip({ id: 't1' }), trip({ id: 't3' })])
|
|
const supabase = stampSupabase([])
|
|
const result = await bookMileagePeriod(supabase as never, 'company-1', 'user-1', {
|
|
...params,
|
|
expectedTripIds: ['t1', 't2'],
|
|
})
|
|
expect(result).toEqual({ ok: false, code: 'TRIPS_CHANGED' })
|
|
expect(createJournalEntry).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('rejects a period spanning calendar years', async () => {
|
|
const supabase = stampSupabase([])
|
|
await expect(
|
|
bookMileagePeriod(supabase as never, 'company-1', 'user-1', {
|
|
...params,
|
|
from: '2025-12-20',
|
|
to: '2026-01-10',
|
|
})
|
|
).rejects.toThrow(/kalenderår/)
|
|
})
|
|
|
|
it('refuses a period spanning several employees (BFL motpart)', async () => {
|
|
vi.mocked(fetchAllRows).mockResolvedValue([
|
|
trip({ id: 't1', employee_id: 'emp-1' }),
|
|
trip({ id: 't2', employee_id: null }),
|
|
])
|
|
const result = await bookMileagePeriod(
|
|
stampSupabase([]) as never,
|
|
'company-1',
|
|
'user-1',
|
|
params
|
|
)
|
|
expect(result).toEqual({ ok: false, code: 'MIXED_EMPLOYEES' })
|
|
expect(createJournalEntry).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('returns PERIOD_NOT_OPEN without writing when the entry date is locked', async () => {
|
|
vi.mocked(fetchAllRows).mockResolvedValue([trip({})])
|
|
vi.mocked(resolvePeriodStatusForDate).mockResolvedValue({
|
|
status: 'locked',
|
|
period_id: 'p1',
|
|
} as never)
|
|
const result = await bookMileagePeriod(
|
|
stampSupabase([]) as never,
|
|
'company-1',
|
|
'user-1',
|
|
params
|
|
)
|
|
expect(result).toEqual({ ok: false, code: 'PERIOD_NOT_OPEN' })
|
|
expect(createJournalEntry).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('books one balanced verifikat and stamps the trips', async () => {
|
|
const trips = [
|
|
trip({ id: 't1', distance_km: 100 }),
|
|
trip({ id: 't2', distance_km: 50, vehicle_type: 'company_car_electric' }),
|
|
]
|
|
vi.mocked(fetchAllRows).mockResolvedValue(trips)
|
|
vi.mocked(resolvePeriodStatusForDate).mockResolvedValue({
|
|
status: 'open',
|
|
period_id: 'period-1',
|
|
} as never)
|
|
vi.mocked(loadPayrollConfig).mockResolvedValue(CONFIG)
|
|
vi.mocked(createJournalEntry).mockResolvedValue({
|
|
id: 'je-1',
|
|
voucher_number: 42,
|
|
voucher_series: 'A',
|
|
} as never)
|
|
|
|
const supabase = stampSupabase([['t1', 't2'], ['t1', 't2']])
|
|
const result = await bookMileagePeriod(supabase as never, 'company-1', 'user-1', params)
|
|
|
|
expect(result).toMatchObject({
|
|
ok: true,
|
|
journalEntryId: 'je-1',
|
|
voucherNumber: 42,
|
|
tripCount: 2,
|
|
totalAmount: 297.5,
|
|
})
|
|
|
|
const input = vi.mocked(createJournalEntry).mock.calls[0][3]
|
|
expect(input.fiscal_period_id).toBe('period-1')
|
|
expect(input.source_type).toBe('manual')
|
|
const debits = input.lines.filter((l) => l.debit_amount > 0)
|
|
const credit = input.lines.find((l) => l.credit_amount > 0)
|
|
expect(debits).toHaveLength(2)
|
|
expect(debits.every((l) => l.account_number === '7331')).toBe(true)
|
|
expect(credit?.account_number).toBe('2820')
|
|
const totalDebit = debits.reduce((sum, l) => sum + l.debit_amount, 0)
|
|
expect(Math.round(totalDebit * 100) / 100).toBe(credit?.credit_amount)
|
|
})
|
|
|
|
it('surfaces STAMP_FAILED with the entry id when the entry link mismatches', async () => {
|
|
vi.mocked(fetchAllRows).mockResolvedValue([
|
|
trip({ id: 't1' }),
|
|
trip({ id: 't2' }),
|
|
])
|
|
vi.mocked(resolvePeriodStatusForDate).mockResolvedValue({
|
|
status: 'open',
|
|
period_id: 'period-1',
|
|
} as never)
|
|
vi.mocked(loadPayrollConfig).mockResolvedValue(CONFIG)
|
|
vi.mocked(createJournalEntry).mockResolvedValue({ id: 'je-1' } as never)
|
|
|
|
// Claim succeeds for both trips; the journal_entry_id backfill only lands
|
|
// on one row.
|
|
const supabase = stampSupabase([['t1', 't2'], ['t1']])
|
|
const result = await bookMileagePeriod(supabase as never, 'company-1', 'user-1', params)
|
|
expect(result).toEqual({ ok: false, code: 'STAMP_FAILED', journalEntryId: 'je-1' })
|
|
})
|
|
|
|
it('reverts the claim when verifikat creation fails', async () => {
|
|
vi.mocked(fetchAllRows).mockResolvedValue([trip({ id: 't1' })])
|
|
vi.mocked(resolvePeriodStatusForDate).mockResolvedValue({
|
|
status: 'open',
|
|
period_id: 'period-1',
|
|
} as never)
|
|
vi.mocked(loadPayrollConfig).mockResolvedValue(CONFIG)
|
|
vi.mocked(createJournalEntry).mockRejectedValue(new Error('period locked'))
|
|
|
|
const supabase = stampSupabase([['t1']])
|
|
await expect(
|
|
bookMileagePeriod(supabase as never, 'company-1', 'user-1', params)
|
|
).rejects.toThrow('period locked')
|
|
// Claim + revert both went through the update chain.
|
|
expect(supabase.chain.update).toHaveBeenCalledWith({ status: 'booked' })
|
|
expect(supabase.chain.update).toHaveBeenCalledWith({ status: 'draft' })
|
|
})
|
|
})
|