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>
This commit is contained in:
co-authored by
Claude Fable 5.1
Jakob Wennberg
parent
0069a3f59a
commit
4fce2d7b94
@@ -34,6 +34,11 @@ import { withApiV1 } from '@/lib/api/v1/with-api-v1'
|
||||
import { v1ErrorResponse, v1ErrorResponseFromCode } from '@/lib/api/v1/errors'
|
||||
import { checkPeriodLock } from '@/lib/api/v1/check-period-lock'
|
||||
import { createSalaryRunEntries } from '@/lib/salary/salary-entries'
|
||||
import {
|
||||
assertLinkedExpenseClaimsOpen,
|
||||
rosterHasLinkedExpenseClaims,
|
||||
settleExpenseClaimsForBookedRun,
|
||||
} from '@/lib/salary/expense-claim-lines'
|
||||
import { isFSkattStatus } from '@/lib/salary/declared-avgifter'
|
||||
import { syncVacationLedgerForEmployees } from '@/lib/salary/vacation-ledger'
|
||||
import { isBookkeepingError } from '@/lib/bookkeeping/errors'
|
||||
@@ -169,6 +174,20 @@ export const POST = withApiV1<{ params: Promise<{ companyId: string; id: string
|
||||
})
|
||||
}
|
||||
|
||||
// Utlägg repaid with this salary (#2331): every linked claim must still
|
||||
// be open BEFORE anything is posted (mirrors lib/salary/book-run.ts).
|
||||
const claimsCheck = await assertLinkedExpenseClaimsOpen(
|
||||
ctx.supabase,
|
||||
ctx.companyId!,
|
||||
employees as Array<{ employee_id: string; line_items: Array<Record<string, unknown>> | null }>,
|
||||
)
|
||||
if (!claimsCheck.ok) {
|
||||
return v1ErrorResponseFromCode(claimsCheck.code, ctx.log, {
|
||||
requestId: ctx.requestId,
|
||||
details: claimsCheck.details,
|
||||
})
|
||||
}
|
||||
|
||||
if (ctx.dryRun) {
|
||||
// Without invoking the engine we can't get real voucher numbers, but
|
||||
// we CAN preview the would-be state transition + the expected entry
|
||||
@@ -371,6 +390,28 @@ export const POST = withApiV1<{ params: Promise<{ companyId: string; id: string
|
||||
})
|
||||
}
|
||||
|
||||
// Utlägg repaid with this salary: mark the claims paid with a payout
|
||||
// batch pointing at the salary verifikat (mirrors lib/salary/book-run.ts;
|
||||
// the verifikat is posted, so a failure is logged, never rolled back).
|
||||
if (
|
||||
rosterHasLinkedExpenseClaims(
|
||||
employees as Array<{ employee_id: string; line_items: Array<Record<string, unknown>> | null }>,
|
||||
)
|
||||
) {
|
||||
const settled = await settleExpenseClaimsForBookedRun(ctx.supabase, {
|
||||
companyId: ctx.companyId!,
|
||||
userId: ctx.userId,
|
||||
salaryRunId,
|
||||
})
|
||||
if (!settled.ok) {
|
||||
ctx.log.error(
|
||||
'expense claims NOT settled after salary booking: run is booked with a 2820 debit but the claims are still open; re-run settle_expense_claims_via_salary_run',
|
||||
new Error(settled.detail ?? settled.code),
|
||||
{ salaryRunId, companyId: ctx.companyId, code: settled.code },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Final refresh of the payslip's "Ackumulerat" snapshot, mirroring
|
||||
// lib/salary/book-run.ts. Non-fatal: YTD is display only and never
|
||||
// reaches a verifikation.
|
||||
|
||||
Reference in New Issue
Block a user