Files
accounted/app/api/expense-claims/[id]/route.ts
T
4fce2d7b94 feat(salary): repay utlägg with the salary as a tax-free payslip line (#2361)
* feat(salary): repay utlägg with the salary as a tax-free payslip line (#2331)

- expense_reimbursement line type: kostnadsersättning outside gross, tax,
  avgifter and the AGI. The engine adds tax-free reimbursements (utlägg,
  skattefritt traktamente, skattefri milersättning) to the net payout only.
- booking debits the claim's liability account (2820) on top of gross,
  never a 7xxx cost; a run that only repays utlägg posts 2820 D / 1930 K
  instead of being treated as a nollkörning
- salary_line_items.source_expense_claim_id (tenant-scoped FK, cascade,
  one payslip line per claim); settle_expense_claims_via_salary_run marks
  the claims paid with an expense_payout_batches row pointing at the
  salary verifikat, no second verifikat, idempotent on retry; wired into
  bookLoadedRun and the v1 book route with a pre-check before posting
- create_expense_payout_batch refuses claims scheduled on a payslip
  (ON_PAYSLIP); deleteExpenseClaim refuses once the run has left draft
- "Lägg till utlägg" on the employee row of a draft run; the payslip page
  labels and removes the lines
- pg-real: tests/pg/utlagg-via-lon.pg.test.ts + ON_PAYSLIP case

Claude-Session: https://claude.ai/code/session_01LvMaHcTnwAfxzgYD1fGYX1
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* fix(salary): PR #2361 review: claim delete cannot cascade into a booked payslip; AGI excludes the utlägg line

- salary_line_items_source_expense_claim_fkey is ON DELETE RESTRICT (edited
  in the unmerged 20260906210300): the database refuses to delete a claim a
  payslip line still references, whichever path issues the DELETE
- deleteExpenseClaim removes the draft line first (before the storno) and
  keeps refusing with ON_PAYSLIP once the run has left draft
- pg-real: delete refused with 23503 on a booked and on a draft run; the
  app order (line, then claim) succeeds
- unit: AGI builder keeps FK011/FK001/FK487 and emits no benefit field for
  an expense_reimbursement line (FK011 derives from sre.gross_salary; only
  benefit_* types are read from line items)

Claude-Session: https://claude.ai/code/session_01LvMaHcTnwAfxzgYD1fGYX1
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

---------

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-06 21:17:46 +02:00

58 lines
2.1 KiB
TypeScript

import { NextResponse } from 'next/server'
import { ensureInitialized } from '@/lib/init'
import { withRouteContext } from '@/lib/api/with-route-context'
import { bookkeepingErrorResponse } from '@/lib/bookkeeping/errors'
import { getErrorMessage } from '@/lib/errors/get-error-message'
import { deleteExpenseClaim } from '@/lib/expenses/expense-claims-service'
ensureInitialized()
const DELETE_ERROR_MESSAGES: Record<string, { message: string; status: number }> = {
NOT_FOUND: { message: 'Utlägget hittades inte', status: 404 },
ALREADY_PAID: {
message: 'Utlägget är redan utbetalt och kan inte tas bort.',
status: 409,
},
ON_PAYSLIP: {
message: 'Utlägget ligger på ett lönebesked som är under behandling. Ta bort raden från lönebeskedet först.',
status: 409,
},
UNLINKED: {
message: 'Utlägget saknar koppling till sitt verifikat och kan inte tas bort automatiskt.',
status: 409,
},
DELETE_FAILED: { message: 'Utlägget kunde inte tas bort.', status: 500 },
}
export const DELETE = withRouteContext<{ params: Promise<{ id: string }> }>(
'expense_claims.delete',
async (_request, { supabase, companyId, user, log }, { params }) => {
const { id } = await params
try {
const result = await deleteExpenseClaim(supabase, companyId, user.id, id)
if (!result.ok) {
const mapped = DELETE_ERROR_MESSAGES[result.code] ?? {
message: 'Utlägget kunde inte tas bort.',
status: 500,
}
if (mapped.status >= 500) {
log.error('expense claim delete failed', new Error(result.detail ?? result.code))
}
return NextResponse.json({ error: mapped.message, code: result.code }, { status: mapped.status })
}
return NextResponse.json({
data: { id, deleted: true, reversal_entry_id: result.reversal_entry_id },
})
} catch (err) {
const typed = bookkeepingErrorResponse(err)
if (typed) return typed
log.error('failed to delete expense claim', err as Error)
return NextResponse.json(
{ error: getErrorMessage(err, { context: 'journal_entry' }) },
{ status: 500 },
)
}
},
{ requireWrite: true },
)