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:
Mattsson
2026-08-07 14:17:21 +02:00
committed by GitHub
co-authored by Claude Fable 5
parent f7f3a31f8e
commit 7411a0171b
30 changed files with 3656 additions and 2 deletions
+77
View File
@@ -3164,3 +3164,80 @@ export const DimensionTaggingApplySchema = z.object({
dimensions: DimensionsBagSchema,
reason: z.string().trim().min(3).max(500),
})
// ============================================================
// Körjournal (mileage trips)
// ============================================================
const mileageVehicleType = z.enum(['own_car', 'company_car_fossil', 'company_car_electric'])
export const CreateMileageTripSchema = z
.object({
trip_date: saneIsoDate,
vehicle_type: mileageVehicleType.default('own_car'),
vehicle_registration: z.string().trim().max(20).optional().nullable(),
odometer_start: z.number().int().nonnegative().optional().nullable(),
odometer_end: z.number().int().nonnegative().optional().nullable(),
distance_km: z.number().positive().max(100000),
from_location: z.string().trim().min(1).max(200),
to_location: z.string().trim().min(1).max(200),
purpose: z.string().trim().min(1).max(500),
visited: z.string().trim().max(200).optional().nullable(),
is_round_trip: z.boolean().default(false),
employee_id: uuid.optional().nullable(),
notes: z.string().trim().max(1000).optional().nullable(),
})
.refine(
(t) =>
t.odometer_start == null || t.odometer_end == null || t.odometer_end > t.odometer_start,
{ message: 'Mätarställning vid ankomst måste vara högre än vid start' }
)
.refine((t) => t.vehicle_type === 'own_car' || Boolean(t.vehicle_registration?.trim()), {
message: 'Ange registreringsnummer för förmånsbilen',
})
export const UpdateMileageTripSchema = z
.object({
trip_date: saneIsoDate.optional(),
vehicle_type: mileageVehicleType.optional(),
vehicle_registration: z.string().trim().max(20).optional().nullable(),
odometer_start: z.number().int().nonnegative().optional().nullable(),
odometer_end: z.number().int().nonnegative().optional().nullable(),
distance_km: z.number().positive().max(100000).optional(),
from_location: z.string().trim().min(1).max(200).optional(),
to_location: z.string().trim().min(1).max(200).optional(),
purpose: z.string().trim().min(1).max(500).optional(),
visited: z.string().trim().max(200).optional().nullable(),
is_round_trip: z.boolean().optional(),
employee_id: uuid.optional().nullable(),
notes: z.string().trim().max(1000).optional().nullable(),
})
.refine((t) => Object.keys(t).length > 0, { message: 'Inga fält att uppdatera' })
export const BookMileagePeriodSchema = z
.object({
from: saneIsoDate,
to: saneIsoDate,
entry_date: saneIsoDate,
counter_account: z.enum(['2820', '2893', '1930']).default('2820'),
employee_id: uuid.optional(),
})
.refine((p) => p.from <= p.to, { message: 'Ogiltigt datumintervall' })
// Schablon rates are per calendar year: a cross-year period would book
// every trip at one year's rate.
.refine((p) => p.from.slice(0, 4) === p.to.slice(0, 4), {
message: 'Milersättning bokförs per kalenderår: dela upp perioden per år',
})
export const MileageSalaryPushSchema = z
.object({
run_id: uuid,
employee_id: uuid,
from: saneIsoDate,
to: saneIsoDate,
include_unassigned: z.boolean().default(true),
})
.refine((p) => p.from <= p.to, { message: 'Ogiltigt datumintervall' })
.refine((p) => p.from.slice(0, 4) === p.to.slice(0, 4), {
message: 'Milersättning bokförs per kalenderår: dela upp perioden per år',
})
+7 -2
View File
@@ -19,8 +19,8 @@ export const API_KEY_SCOPES = {
'suppliers:write': { label: 'Leverantörer: skriv', description: 'Skapa leverantörer; godkänn, kreditera, betal-länka och hantera leverantörsfakturor (6 verktyg)' },
'reports:read': { label: 'Rapporter: läs', description: 'Kontoplan, huvudbok, balansräkning, resultaträkning, moms, KPI, reskontra, perioder, bankavstämning, SIE-export (12 verktyg)' },
'bookkeeping:write': { label: 'Bokföring: skriv', description: 'Stänga/låsa perioder, ingående balans, bokslut, SIE-import, voucher-gap-förklaringar, kontoplan (skapa/ändra konton), verifikat-anteckningar' },
'payroll:read': { label: 'Löner: läs', description: 'Lista anställda, lönekörningar, lönejournal (3 verktyg)' },
'payroll:write': { label: 'Löner: skriv', description: 'Skapa lönekörning, beräkna, generera AGI (3 verktyg)' },
'payroll:read': { label: 'Löner: läs', description: 'Lista anställda, lönekörningar, lönejournal, körjournal' },
'payroll:write': { label: 'Löner: skriv', description: 'Skapa lönekörning, beräkna, generera AGI, logga körjournalresor' },
// v1 REST API: added Phase 1
'companies:read': { label: 'Företag: läs', description: 'Lista och visa företagsprofiler som API-nyckeln har tillgång till' },
'companies:write': { label: 'Företag: skriv', description: 'Uppdatera företagsinställningar via stagade verktyg eller REST-endpointen PATCH /api/v1/companies/{companyId}/settings' },
@@ -241,6 +241,11 @@ export const TOOL_SCOPE_MAP: Record<string, ApiKeyScope> = {
gnubok_get_document_content: 'transactions:read',
gnubok_attach_document_to_transaction: 'transactions:write',
gnubok_link_document_to_voucher: 'bookkeeping:write',
// Körjournal (mileage): trip log reads/writes are payroll surface
// (milersättning, 7331); booking the verifikat is a journal write.
gnubok_list_mileage_trips: 'payroll:read',
gnubok_log_mileage_trip: 'payroll:write',
gnubok_book_mileage_period: 'bookkeeping:write',
// Payroll
gnubok_list_employees: 'payroll:read',
gnubok_get_salary_run: 'payroll:read',
+82
View File
@@ -0,0 +1,82 @@
import { describe, expect, it } from 'vitest'
import { mileageTripsToCsv } from '@/lib/mileage/csv-export'
import type { MileageTrip } from '@/types'
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: 'ABC123',
odometer_start: 1000,
odometer_end: 1032,
distance_km: 32.3,
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,
}
}
describe('mileageTripsToCsv', () => {
it('starts with a UTF-8 BOM and the Swedish header row', () => {
const csv = mileageTripsToCsv([])
expect(csv.charCodeAt(0)).toBe(0xfeff)
expect(csv).toContain('Datum;Förare;Fordon;Registreringsnummer')
})
it('renders decimal comma, odometer readings and status labels', () => {
const csv = mileageTripsToCsv([trip({})])
const row = csv.split('\r\n')[1]
expect(row).toContain('2026-05-10;;Egen bil;ABC123;1000;1032;32,3;Kontoret;Kunden')
expect(row).toContain('Utkast')
})
it('names the driver for employee-attributed trips', () => {
const csv = mileageTripsToCsv(
[trip({ employee_id: 'emp-1' })],
new Map(),
new Map([['emp-1', 'Anna Andersson']])
)
expect(csv.split('\r\n')[1]).toContain('2026-05-10;Anna Andersson;Egen bil')
})
it('neutralizes formula-injection triggers in user text', () => {
const csv = mileageTripsToCsv([
trip({ purpose: '=HYPERLINK("http://evil","x")', from_location: '+SUM(A1)', visited: '@cmd' }),
])
const row = csv.split('\r\n')[1]
expect(row).toContain(`'=HYPERLINK`)
expect(row).toContain(`'+SUM(A1)`)
expect(row).toContain(`'@cmd`)
})
it('quotes fields containing separators and escapes quotes', () => {
const csv = mileageTripsToCsv([
trip({ purpose: 'Möte; leverans', visited: 'Firma "AB"' }),
])
expect(csv).toContain('"Möte; leverans"')
expect(csv).toContain('"Firma ""AB"""')
})
it('maps journal entry ids to voucher labels for booked trips', () => {
const csv = mileageTripsToCsv(
[trip({ status: 'booked', journal_entry_id: 'je-1' })],
new Map([['je-1', 'A42']])
)
const row = csv.split('\r\n')[1]
expect(row).toContain('Bokförd;A42')
})
})
@@ -0,0 +1,449 @@
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' })
})
})
+76
View File
@@ -0,0 +1,76 @@
import type { MileageTrip } from '@/types'
/**
* Körjournal CSV export for Skatteverket audit purposes. Column labels are
* statutory-adjacent Swedish terms and stay Swedish in both locales (same
* policy as SIE/INK2 exports). Semicolon-separated with a UTF-8 BOM so
* Swedish Excel opens it correctly.
*/
const HEADERS = [
'Datum',
'Förare',
'Fordon',
'Registreringsnummer',
'Mätarställning start',
'Mätarställning slut',
'Antal km',
'Från',
'Till',
'Ärende',
'Besökt (kund/plats)',
'Tur och retur',
'Status',
'Verifikat',
] as const
const VEHICLE_LABELS: Record<MileageTrip['vehicle_type'], string> = {
own_car: 'Egen bil',
company_car_fossil: 'Förmånsbil (bensin/diesel)',
company_car_electric: 'Förmånsbil (el)',
}
function csvField(value: string | number | null | undefined): string {
if (value === null || value === undefined || value === '') return ''
let text = String(value)
// Formula-injection guard (OWASP CSV injection): user-entered text starting
// with a formula trigger would execute when the export opens in Excel.
// Neutralize with a leading apostrophe; spreadsheet apps hide it.
if (/^[=+@\t\r-]/.test(text)) {
text = `'${text}`
}
if (/[";\n\r]/.test(text)) {
return `"${text.replace(/"/g, '""')}"`
}
return text
}
export function mileageTripsToCsv(
trips: MileageTrip[],
voucherLabels: Map<string, string> = new Map(),
driverLabels: Map<string, string> = new Map()
): string {
const rows = trips.map((trip) =>
[
trip.trip_date,
trip.employee_id ? (driverLabels.get(trip.employee_id) ?? '') : '',
VEHICLE_LABELS[trip.vehicle_type],
trip.vehicle_registration,
trip.odometer_start,
trip.odometer_end,
// Swedish decimal comma for Excel.
String(trip.distance_km).replace('.', ','),
trip.from_location,
trip.to_location,
trip.purpose,
trip.visited,
trip.is_round_trip ? 'Ja' : 'Nej',
trip.status === 'booked' ? 'Bokförd' : 'Utkast',
trip.journal_entry_id ? (voucherLabels.get(trip.journal_entry_id) ?? '') : '',
]
.map(csvField)
.join(';')
)
return '\uFEFF' + [HEADERS.join(';'), ...rows].join('\r\n') + '\r\n'
}
+499
View File
@@ -0,0 +1,499 @@
import type { SupabaseClient } from '@supabase/supabase-js'
import type {
CreateMileageTripInput,
MileagePeriodSummary,
MileageTrip,
MileageVehicleType,
} from '@/types'
import { createJournalEntry } from '@/lib/bookkeeping/engine'
import { loadPayrollConfig, type PayrollConfig } from '@/lib/salary/payroll-config'
import { getLineItemAccount } from '@/lib/salary/account-mapping'
import { fetchAllRows } from '@/lib/supabase/fetch-all'
import { resolvePeriodStatusForDate } from '@/lib/core/bookkeeping/period-service'
import { roundOre } from '@/lib/money'
/**
* Körjournal service: trip log per Skatteverket documentation requirements
* and milersättning booking.
*
* Rates come from the DB-driven payroll config (salary_payroll_config), never
* hardcoded. V1 always reimburses at exactly the tax-free schablon, so no
* taxable excess arises; the 7332 path exists in the salary module for
* companies that pay above schablon through payroll.
*/
const KM_PER_MIL = 10
/** BAS 7331: skattefria bilersättningar. */
const MILEAGE_TAXFREE_ACCOUNT = getLineItemAccount('mileage_taxfree')
/** Counter accounts a mileage verifikat may credit. */
export const MILEAGE_COUNTER_ACCOUNTS = ['2820', '2893', '1930'] as const
export type MileageCounterAccount = (typeof MILEAGE_COUNTER_ACCOUNTS)[number]
const round2 = roundOre
export function ratePerMil(config: PayrollConfig, vehicleType: MileageVehicleType): number {
switch (vehicleType) {
case 'own_car':
return config.milersattningEgenBil
case 'company_car_fossil':
return config.milersattningFormansbilFossil
case 'company_car_electric':
return config.milersattningFormansbilEl
}
}
const VEHICLE_TYPE_LABELS: Record<MileageVehicleType, string> = {
own_car: 'egen bil',
company_car_fossil: 'förmånsbil (bensin/diesel)',
company_car_electric: 'förmånsbil (el)',
}
/**
* Aggregate trips into per-vehicle-type totals at the schablon rate.
* Amounts are rounded once per vehicle-type group (cents-integer math),
* so the group amounts sum exactly to the verifikat total.
*/
export function summarizeTrips(
trips: Pick<MileageTrip, 'vehicle_type' | 'distance_km'>[],
config: PayrollConfig
): MileagePeriodSummary[] {
const groups = new Map<MileageVehicleType, { km: number; count: number }>()
for (const trip of trips) {
const group = groups.get(trip.vehicle_type) || { km: 0, count: 0 }
group.km = round2(group.km + Number(trip.distance_km))
group.count += 1
groups.set(trip.vehicle_type, group)
}
const summaries: MileagePeriodSummary[] = []
for (const [vehicleType, group] of groups) {
const mil = round2(group.km / KM_PER_MIL)
const rate = ratePerMil(config, vehicleType)
summaries.push({
vehicle_type: vehicleType,
trip_count: group.count,
total_km: group.km,
total_mil: mil,
rate_per_mil: rate,
amount: round2(mil * rate),
})
}
return summaries.sort((a, b) => a.vehicle_type.localeCompare(b.vehicle_type))
}
export interface ListTripsFilter {
from?: string
to?: string
status?: 'draft' | 'booked'
employeeId?: string
}
export async function listTrips(
supabase: SupabaseClient,
companyId: string,
filter: ListTripsFilter = {}
): Promise<MileageTrip[]> {
return fetchAllRows<MileageTrip>(({ from, to }) => {
let query = supabase
.from('mileage_trips')
.select('*')
.eq('company_id', companyId)
.order('trip_date', { ascending: false })
.order('created_at', { ascending: false })
.range(from, to)
if (filter.from) query = query.gte('trip_date', filter.from)
if (filter.to) query = query.lte('trip_date', filter.to)
if (filter.status) query = query.eq('status', filter.status)
if (filter.employeeId) query = query.eq('employee_id', filter.employeeId)
return query
})
}
export async function createTrip(
supabase: SupabaseClient,
companyId: string,
userId: string,
input: CreateMileageTripInput
): Promise<MileageTrip> {
// A körjournal for a förmånsbil must identify the vehicle: the schablon
// rate depends on which car was driven, and Skatteverket expects the
// underlag to name it. Own-car trips may omit it (single private vehicle).
if ((input.vehicle_type ?? 'own_car') !== 'own_car' && !input.vehicle_registration?.trim()) {
throw new Error('Ange registreringsnummer för förmånsbilen')
}
// The FK on employee_id is not company-scoped; without this check a
// cross-company employee UUID would attach silently.
if (input.employee_id) {
const { data: employee } = await supabase
.from('employees')
.select('id')
.eq('company_id', companyId)
.eq('id', input.employee_id)
.maybeSingle()
if (!employee) {
throw new Error('Den anställda hittades inte i företaget')
}
}
const { data, error } = await supabase
.from('mileage_trips')
.insert({
company_id: companyId,
user_id: userId,
employee_id: input.employee_id || null,
trip_date: input.trip_date,
vehicle_type: input.vehicle_type || 'own_car',
vehicle_registration: input.vehicle_registration?.trim() || null,
odometer_start: input.odometer_start ?? null,
odometer_end: input.odometer_end ?? null,
// The column is numeric(10,1): round to what will actually be stored.
distance_km: Math.round(input.distance_km * 10) / 10,
from_location: input.from_location.trim(),
to_location: input.to_location.trim(),
purpose: input.purpose.trim(),
visited: input.visited?.trim() || null,
is_round_trip: input.is_round_trip ?? false,
notes: input.notes?.trim() || null,
created_via: input.created_via || 'manual',
})
.select()
.single()
if (error || !data) {
throw new Error(`Failed to create mileage trip: ${error?.message ?? 'no row returned'}`)
}
return data as MileageTrip
}
export type BookMileageResult =
| {
ok: true
journalEntryId: string
voucherNumber: number | null
voucherSeries: string | null
tripCount: number
totalAmount: number
summaries: MileagePeriodSummary[]
}
| {
ok: false
code:
| 'NO_TRIPS'
| 'MIXED_EMPLOYEES'
| 'PERIOD_NOT_OPEN'
| 'CLAIM_LOST'
| 'TRIPS_CHANGED'
| 'STAMP_FAILED'
journalEntryId?: string
}
/**
* Book all draft trips in [from, to] as one milersättning verifikat:
* debit 7331 per vehicle type, credit the chosen counter account
* (2820 skuld till anställda, 2893 avräkning aktieägare, or 1930 when the
* payout already left the bank). Trips are stamped booked + linked to the
* verifikat afterwards; the trip rows are the körjournal underlag (7-year
* retention via DB trigger).
*/
export async function bookMileagePeriod(
supabase: SupabaseClient,
companyId: string,
userId: string,
params: {
from: string
to: string
counterAccount: MileageCounterAccount
entryDate: string
employeeId?: string
createdVia?: 'manual' | 'mcp'
/**
* When set (staged MCP approvals), the commit only proceeds if the
* current draft-trip set matches exactly what was previewed at staging
* time: otherwise the approved amount and the booked amount could drift.
*/
expectedTripIds?: string[]
}
): Promise<BookMileageResult> {
// Rates are per calendar year; a period spanning a year boundary would book
// every trip at one year's schablon. Callers book per year (schema-enforced
// in the API; belt-and-braces here for direct service callers).
if (params.from.slice(0, 4) !== params.to.slice(0, 4)) {
throw new Error('Milersättning bokförs per kalenderår: dela upp perioden per år')
}
// Release orphaned claims from a crashed earlier booking (status booked,
// no verifikat, no salary run) so their trips become bookable again. The
// 5-minute age guard keeps a concurrent in-flight booking's claim safe.
const staleBefore = new Date(Date.now() - 5 * 60 * 1000).toISOString()
await supabase
.from('mileage_trips')
.update({ status: 'draft' })
.eq('company_id', companyId)
.eq('status', 'booked')
.is('journal_entry_id', null)
.is('salary_run_id', null)
.lt('updated_at', staleBefore)
.gte('trip_date', params.from)
.lte('trip_date', params.to)
const trips = await listTrips(supabase, companyId, {
from: params.from,
to: params.to,
status: 'draft',
employeeId: params.employeeId,
})
if (trips.length === 0) {
return { ok: false, code: 'NO_TRIPS' }
}
if (params.expectedTripIds) {
const expected = new Set(params.expectedTripIds)
const actual = new Set(trips.map((t) => t.id))
const sameSet =
expected.size === actual.size && [...expected].every((id) => actual.has(id))
if (!sameSet) {
return { ok: false, code: 'TRIPS_CHANGED' }
}
}
// BFL 5 kap 6-7 §: the verifikat must identify its motpart. A single lump
// credit on 2820 covering several employees' reimbursements loses that, so
// a period spanning more than one employee (unassigned trips count as the
// owner's) must be booked per employee via the employeeId filter.
const distinctEmployees = new Set(trips.map((t) => t.employee_id ?? 'unassigned'))
if (distinctEmployees.size > 1) {
return { ok: false, code: 'MIXED_EMPLOYEES' }
}
const period = await resolvePeriodStatusForDate(supabase, companyId, params.entryDate)
if (period.status !== 'open' || !period.period_id) {
return { ok: false, code: 'PERIOD_NOT_OPEN' }
}
// Date-only strings parse as UTC midnight; getFullYear() reads local time
// and lands in the previous year for January dates in negative UTC offsets.
const config = await loadPayrollConfig(supabase, Number(params.to.slice(0, 4)))
const summaries = summarizeTrips(trips, config)
const totalAmount = round2(summaries.reduce((sum, s) => sum + s.amount, 0))
const totalMil = round2(summaries.reduce((sum, s) => sum + s.total_mil, 0))
// Name the motpart in the verifikationstext when the period is scoped to an
// employee (BFL 5 kap 7 §): the trip rows carry the id, the verifikat the name.
let motpart = ''
const employeeId = params.employeeId ?? trips[0].employee_id
if (employeeId) {
const { data: employee } = await supabase
.from('employees')
.select('first_name, last_name')
.eq('company_id', companyId)
.eq('id', employeeId)
.maybeSingle()
if (employee) motpart = `, ${employee.first_name} ${employee.last_name}`
}
// Claim the trips BEFORE creating the verifikat: the draft→booked CAS is
// what makes a concurrent second booking (double-click, retry, two users)
// lose the race instead of producing a duplicate verifikat for the same
// trips. A partially lost race (someone claimed a subset first) aborts and
// reverts rather than booking a set nobody previewed.
const tripIds = trips.map((t) => t.id)
const { data: claimed, error: claimError } = await supabase
.from('mileage_trips')
.update({ status: 'booked' })
.eq('company_id', companyId)
.eq('status', 'draft')
.in('id', tripIds)
.select('id')
if (claimError) {
throw new Error(`Failed to claim mileage trips: ${claimError.message}`)
}
const claimedIds = (claimed ?? []).map((row) => row.id as string)
const revertClaim = async () => {
if (claimedIds.length === 0) return
await supabase
.from('mileage_trips')
.update({ status: 'draft' })
.eq('company_id', companyId)
.eq('status', 'booked')
.is('journal_entry_id', null)
.in('id', claimedIds)
}
if (claimedIds.length !== tripIds.length) {
// A concurrent booking claimed part of the set first: distinct from
// "nothing to book" so the caller can say "reload and retry".
await revertClaim()
return { ok: false, code: 'CLAIM_LOST' }
}
let entry
try {
entry = await createJournalEntry(supabase, companyId, userId, {
fiscal_period_id: period.period_id,
entry_date: params.entryDate,
description: `Milersättning ${params.from} till ${params.to} (${trips.length} resor, ${totalMil} mil${motpart})`,
source_type: 'manual',
lines: [
...summaries.map((s) => ({
account_number: MILEAGE_TAXFREE_ACCOUNT,
debit_amount: s.amount,
credit_amount: 0,
line_description: `Milersättning ${VEHICLE_TYPE_LABELS[s.vehicle_type]}: ${s.total_mil} mil × ${s.rate_per_mil} kr`,
})),
{
account_number: params.counterAccount,
debit_amount: 0,
credit_amount: totalAmount,
line_description: 'Milersättning att utbetala',
},
],
})
} catch (err) {
await revertClaim()
throw err
}
const { data: linked, error: linkError } = await supabase
.from('mileage_trips')
.update({ journal_entry_id: entry.id })
.eq('company_id', companyId)
.eq('status', 'booked')
.in('id', claimedIds)
.select('id')
if (linkError || !linked || linked.length !== claimedIds.length) {
// The verifikat exists and the trips are booked, but some rows lost the
// entry link. Surface loudly so the körjournal can be repaired.
return { ok: false, code: 'STAMP_FAILED', journalEntryId: entry.id }
}
return {
ok: true,
journalEntryId: entry.id,
voucherNumber: entry.voucher_number ?? null,
voucherSeries: entry.voucher_series ?? null,
tripCount: trips.length,
totalAmount,
summaries,
}
}
export type PushToSalaryRunResult =
| { ok: true; tripCount: number; totalAmount: number; summaries: MileagePeriodSummary[] }
| {
ok: false
code:
| 'NO_TRIPS'
| 'RUN_NOT_FOUND'
| 'RUN_NOT_EDITABLE'
| 'EMPLOYEE_NOT_IN_RUN'
| 'CLAIM_LOST'
}
/**
* Push the period's draft trips into a draft/review salary run as
* mileage_taxfree line items (kostnadsersättning: not taxable, no avgifter,
* not semesterlönegrundande). The salary run's own booking flow then carries
* the amounts into the verifikat and AGI.
*/
export async function pushMileageToSalaryRun(
supabase: SupabaseClient,
companyId: string,
params: {
runId: string
employeeId: string
from: string
to: string
includeUnassigned?: boolean
}
): Promise<PushToSalaryRunResult> {
const { data: run } = await supabase
.from('salary_runs')
.select('id, status')
.eq('id', params.runId)
.eq('company_id', companyId)
.single()
if (!run) return { ok: false, code: 'RUN_NOT_FOUND' }
if (run.status !== 'draft' && run.status !== 'review') {
return { ok: false, code: 'RUN_NOT_EDITABLE' }
}
const { data: sre } = await supabase
.from('salary_run_employees')
.select('id')
.eq('salary_run_id', params.runId)
.eq('employee_id', params.employeeId)
.eq('company_id', companyId)
.single()
if (!sre) return { ok: false, code: 'EMPLOYEE_NOT_IN_RUN' }
const all = await listTrips(supabase, companyId, {
from: params.from,
to: params.to,
status: 'draft',
})
const includeUnassigned = params.includeUnassigned ?? true
const trips = all.filter(
(t) =>
t.employee_id === params.employeeId || (includeUnassigned && t.employee_id === null)
)
if (trips.length === 0) return { ok: false, code: 'NO_TRIPS' }
const config = await loadPayrollConfig(supabase, Number(params.to.slice(0, 4)))
const summaries = summarizeTrips(trips, config)
const totalAmount = round2(summaries.reduce((sum, s) => sum + s.amount, 0))
// Claim the trips BEFORE inserting the salary lines: a retry after a
// partial failure would otherwise insert the mileage_taxfree items twice
// for the same trips (double pay). A lost claim reverts and reports.
const tripIds = trips.map((t) => t.id)
const { data: claimed, error: claimError } = await supabase
.from('mileage_trips')
.update({ status: 'booked', salary_run_id: params.runId })
.eq('company_id', companyId)
.eq('status', 'draft')
.in('id', tripIds)
.select('id')
if (claimError) {
throw new Error(`Failed to claim mileage trips: ${claimError.message}`)
}
const claimedIds = (claimed ?? []).map((row) => row.id as string)
const revertClaim = async () => {
if (claimedIds.length === 0) return
await supabase
.from('mileage_trips')
.update({ status: 'draft', salary_run_id: null })
.eq('company_id', companyId)
.eq('status', 'booked')
.is('journal_entry_id', null)
.in('id', claimedIds)
}
if (claimedIds.length !== tripIds.length) {
await revertClaim()
return { ok: false, code: 'CLAIM_LOST' }
}
const { error: itemError } = await supabase.from('salary_line_items').insert(
summaries.map((s, index) => ({
salary_run_employee_id: sre.id,
company_id: companyId,
item_type: 'mileage_taxfree',
description: `Milersättning ${VEHICLE_TYPE_LABELS[s.vehicle_type]} ${params.from} till ${params.to} (${s.trip_count} resor)`,
quantity: s.total_mil,
unit_price: s.rate_per_mil,
amount: s.amount,
is_taxable: false,
is_avgift_basis: false,
is_vacation_basis: false,
account_number: MILEAGE_TAXFREE_ACCOUNT,
sort_order: 100 + index,
}))
)
if (itemError) {
await revertClaim()
throw new Error(`Failed to add mileage line items: ${itemError.message}`)
}
return { ok: true, tripCount: trips.length, totalAmount, summaries }
}
+106
View File
@@ -4661,6 +4661,106 @@ async function commitCreateSalaryRun(
}
}
async function commitLogMileageTrip(
supabase: SupabaseClient,
userId: string,
companyId: string,
params: Record<string, unknown>
): Promise<ExecutorResult> {
try {
const { createTrip } = await import('@/lib/mileage/mileage-service')
const trip = await createTrip(supabase, companyId, userId, {
trip_date: params.trip_date as string,
vehicle_type: params.vehicle_type as never,
vehicle_registration: (params.vehicle_registration as string) || null,
odometer_start: (params.odometer_start as number) ?? null,
odometer_end: (params.odometer_end as number) ?? null,
distance_km: params.distance_km as number,
from_location: params.from_location as string,
to_location: params.to_location as string,
purpose: params.purpose as string,
visited: (params.visited as string) || null,
is_round_trip: params.is_round_trip === true,
employee_id: (params.employee_id as string) || null,
notes: (params.notes as string) || null,
created_via: 'mcp',
})
return {
data: {
mileage_trip_id: trip.id,
trip_date: trip.trip_date,
distance_km: trip.distance_km,
status: trip.status,
},
}
} catch (err) {
const message = err instanceof Error ? err.message : 'Failed to log mileage trip'
// Input-validation failures from the service are permanent for these
// params: 400 so agents fix the arguments instead of retrying blindly.
const isValidation = /registreringsnummer|hittades inte/i.test(message)
return { error: message, status: isValidation ? 400 : 500 }
}
}
async function commitBookMileagePeriod(
supabase: SupabaseClient,
userId: string,
companyId: string,
params: Record<string, unknown>
): Promise<ExecutorResult> {
try {
const { bookMileagePeriod } = await import('@/lib/mileage/mileage-service')
const result = await bookMileagePeriod(supabase, companyId, userId, {
from: params.from as string,
to: params.to as string,
entryDate: params.entry_date as string,
counterAccount: (params.counter_account as never) || '2820',
employeeId: (params.employee_id as string) || undefined,
createdVia: 'mcp',
// Staged approvals freeze the trip set: what was previewed is exactly
// what may be booked; drift fails the commit instead of booking blind.
expectedTripIds: Array.isArray(params.trip_ids)
? (params.trip_ids as string[])
: undefined,
})
if (!result.ok) {
if (result.code === 'NO_TRIPS') {
return { error: 'No unbooked trips in the selected period', status: 400 }
}
if (result.code === 'MIXED_EMPLOYEES') {
return { error: 'The period spans several employees; book per employee via employee_id', status: 400 }
}
if (result.code === 'PERIOD_NOT_OPEN') {
return { error: 'The entry date falls in a closed or locked period', status: 400 }
}
if (result.code === 'TRIPS_CHANGED' || result.code === 'CLAIM_LOST') {
return {
error: 'The körjournal changed since this booking was staged; stage it again to get a fresh preview',
status: 409,
}
}
return {
error: `Voucher ${result.journalEntryId} was created but trips could not all be linked; review the körjournal before booking again`,
status: 500,
}
}
return {
data: {
journal_entry_id: result.journalEntryId,
voucher: `${result.voucherSeries ?? ''}${result.voucherNumber ?? ''}`,
trip_count: result.tripCount,
total_amount: result.totalAmount,
summaries: result.summaries,
},
}
} catch (err) {
return {
error: err instanceof Error ? err.message : 'Failed to book mileage period',
status: 500,
}
}
}
async function commitGenerateAgi(
supabase: SupabaseClient,
userId: string,
@@ -5572,6 +5672,12 @@ async function commitPendingOperationInner(
case 'create_salary_run':
result = await commitCreateSalaryRun(supabase, userId, companyId, pendingOp.params)
break
case 'log_mileage_trip':
result = await commitLogMileageTrip(supabase, userId, companyId, pendingOp.params)
break
case 'book_mileage_period':
result = await commitBookMileagePeriod(supabase, userId, companyId, pendingOp.params)
break
case 'generate_agi':
result = await commitGenerateAgi(supabase, userId, companyId, pendingOp.params)
break
+10
View File
@@ -198,6 +198,16 @@ export const OPERATION_RISK_TIERS: Record<string, RiskLevel> = {
// both attach an existing booking to a different entity.
link_transaction_journal_entry: 'medium',
// ── Körjournal (mileage) ───────────────────────────────────────────
// A trip row is pure travel documentation: no booking impact until a
// separate book operation. Same tier as create_customer.
log_mileage_trip: 'low',
// Books one verifikat with fixed lines derived from logged trips (7331 +
// whitelisted counter account) at the DB-configured schablon rate: not the
// arbitrary-line surface that makes create_voucher 'high'. Reversible via
// storno: same tier as post_annual_depreciation.
book_mileage_period: 'medium',
// ── Skatteverket filing (PR5) ──────────────────────────────────────
// External + irreversible once signed. Commit sends the declaration for
// BankID signing; the user's signature in the browser is the filing act.
+3
View File
@@ -912,6 +912,9 @@ export const MASTER_DATA_DUMP_TABLES: MasterDataTableSpec[] = [
{ name: 'salary_payslip_links', file: 'salary_payslip_links.json' },
{ name: 'shift_premium_rules', file: 'shift_premium_rules.json' },
{ name: 'agi_declarations', file: 'agi_declarations.json', orderBy: 'created_at' },
// Körjournal: trip log underlag for milersättning verifikat (BFL 7-year
// retention per Skatteverket's körjournal documentation requirement).
{ name: 'mileage_trips', file: 'mileage_trips.json', orderBy: 'trip_date' },
// Assets and accruals
{ name: 'assets', file: 'assets.json', orderBy: 'created_at' },
{ name: 'depreciation_schedules', file: 'depreciation_schedules.json', orderBy: 'created_at' },