* 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>
352 lines
12 KiB
TypeScript
352 lines
12 KiB
TypeScript
/**
|
|
* Utlägg on the payslip ("Betala ut via lön", #2331).
|
|
*
|
|
* An employee's registered expense claim (2820 K at registration) can be
|
|
* repaid with the next salary instead of by a bank transfer. The payslip
|
|
* carries one `expense_reimbursement` line per claim, linked through
|
|
* salary_line_items.source_expense_claim_id:
|
|
*
|
|
* - the calculation engine adds the line to the net payout only (no tax,
|
|
* no arbetsgivaravgifter, outside the AGI gross)
|
|
* - the salary verifikat debits the claim's liability account (2820) for it
|
|
* - once the run is booked, settle_expense_claims_via_salary_run marks the
|
|
* claims paid with an expense_payout_batches row that points at the
|
|
* salary verifikat: the same batch mechanism as the bank-side payout,
|
|
* without a second verifikat
|
|
*
|
|
* Everything that touches a draft payslip goes through the same gates as the
|
|
* other line commands (lib/salary/payslip-lines.ts): draft only, the
|
|
* employee must be on the run.
|
|
*/
|
|
|
|
import type { SupabaseClient } from '@supabase/supabase-js'
|
|
import { roundOre, sumOre } from '@/lib/money'
|
|
import {
|
|
assertRunDraft,
|
|
resolveRunEmployee,
|
|
type PayslipLineResult,
|
|
type SalaryLineItemRow,
|
|
} from '@/lib/salary/payslip-lines'
|
|
|
|
/** After mileage (100), absence (200/250), premiums (300); before rounding (900). */
|
|
export const EXPENSE_REIMBURSEMENT_SORT_ORDER = 500
|
|
|
|
const LINE_COLUMNS =
|
|
'id, salary_run_employee_id, company_id, item_type, description, quantity, unit_price, amount, ' +
|
|
'is_taxable, is_avgift_basis, is_vacation_basis, is_gross_deduction, is_net_deduction, ' +
|
|
'account_number, sort_order, source_expense_claim_id, created_at, updated_at'
|
|
|
|
export type ExpenseClaimLineRow = SalaryLineItemRow & { source_expense_claim_id: string | null }
|
|
|
|
export interface OpenExpenseClaim {
|
|
id: string
|
|
description: string
|
|
expense_date: string
|
|
amount_sek: number
|
|
liability_account: string
|
|
}
|
|
|
|
/**
|
|
* The employee's registered claims that are not yet scheduled on any payslip
|
|
* line (this run or another draft). Oldest first, like the worklist.
|
|
*/
|
|
export async function listOpenExpenseClaimsForEmployee(
|
|
supabase: SupabaseClient,
|
|
companyId: string,
|
|
employeeId: string,
|
|
): Promise<OpenExpenseClaim[]> {
|
|
const { data: claims, error } = await supabase
|
|
.from('expense_claims')
|
|
.select('id, description, expense_date, amount_sek, liability_account')
|
|
.eq('company_id', companyId)
|
|
.eq('employee_id', employeeId)
|
|
.eq('status', 'registered')
|
|
.order('expense_date', { ascending: true })
|
|
.order('id', { ascending: true })
|
|
if (error) throw new Error(`Failed to list open expense claims: ${error.message}`)
|
|
const rows = (claims ?? []) as Array<Omit<OpenExpenseClaim, 'amount_sek'> & { amount_sek: number | string }>
|
|
if (rows.length === 0) return []
|
|
|
|
const { data: linked, error: linkedError } = await supabase
|
|
.from('salary_line_items')
|
|
.select('source_expense_claim_id')
|
|
.eq('company_id', companyId)
|
|
.in('source_expense_claim_id', rows.map((r) => r.id))
|
|
if (linkedError) throw new Error(`Failed to read scheduled expense claims: ${linkedError.message}`)
|
|
const scheduled = new Set(
|
|
((linked ?? []) as Array<{ source_expense_claim_id: string | null }>)
|
|
.map((l) => l.source_expense_claim_id)
|
|
.filter((id): id is string => Boolean(id)),
|
|
)
|
|
|
|
return rows
|
|
.filter((r) => !scheduled.has(r.id))
|
|
.map((r) => ({ ...r, amount_sek: roundOre(Number(r.amount_sek)) }))
|
|
}
|
|
|
|
export interface AddedExpenseClaimLines {
|
|
lines: ExpenseClaimLineRow[]
|
|
claim_count: number
|
|
total_sek: number
|
|
}
|
|
|
|
/**
|
|
* "Lägg till öppna utlägg": one expense_reimbursement line per open claim,
|
|
* amount and liability account copied from the claim so the booking relieves
|
|
* exactly what registration booked. The partial unique index on
|
|
* source_expense_claim_id is the last line of defense against the same claim
|
|
* landing on two payslips; a race there surfaces as
|
|
* EXPENSE_CLAIM_ALREADY_ON_PAYSLIP.
|
|
*/
|
|
export async function addOpenExpenseClaimsToPayslip(
|
|
supabase: SupabaseClient,
|
|
args: { companyId: string; salaryRunId: string; employeeId: string },
|
|
): Promise<PayslipLineResult<AddedExpenseClaimLines>> {
|
|
const gate = await assertRunDraft(supabase, args.companyId, args.salaryRunId)
|
|
if (!gate.ok) return gate
|
|
|
|
const sre = await resolveRunEmployee(supabase, args.companyId, args.salaryRunId, {
|
|
employeeId: args.employeeId,
|
|
})
|
|
if (!sre.ok) return sre
|
|
|
|
const claims = await listOpenExpenseClaimsForEmployee(supabase, args.companyId, args.employeeId)
|
|
if (claims.length === 0) {
|
|
return { ok: false, code: 'SALARY_RUN_NO_OPEN_EXPENSE_CLAIMS' }
|
|
}
|
|
|
|
const rows = claims.map((claim, index) => ({
|
|
salary_run_employee_id: sre.data.id,
|
|
company_id: args.companyId,
|
|
item_type: 'expense_reimbursement',
|
|
description: `Utlägg: ${claim.description} (${claim.expense_date})`,
|
|
quantity: null,
|
|
unit_price: null,
|
|
amount: claim.amount_sek,
|
|
is_taxable: false,
|
|
is_avgift_basis: false,
|
|
is_vacation_basis: false,
|
|
is_gross_deduction: false,
|
|
is_net_deduction: false,
|
|
account_number: claim.liability_account,
|
|
sort_order: EXPENSE_REIMBURSEMENT_SORT_ORDER + index,
|
|
source_expense_claim_id: claim.id,
|
|
}))
|
|
|
|
const { data: created, error } = await supabase
|
|
.from('salary_line_items')
|
|
.insert(rows)
|
|
.select(LINE_COLUMNS)
|
|
if (error) {
|
|
if ((error as { code?: string }).code === '23505') {
|
|
return { ok: false, code: 'EXPENSE_CLAIM_ALREADY_ON_PAYSLIP' }
|
|
}
|
|
return { ok: false, code: 'INTERNAL_ERROR', details: { message: error.message } }
|
|
}
|
|
|
|
const lines = (created ?? []) as unknown as ExpenseClaimLineRow[]
|
|
return {
|
|
ok: true,
|
|
data: {
|
|
lines,
|
|
claim_count: claims.length,
|
|
total_sek: sumOre(claims.map((c) => c.amount_sek)),
|
|
},
|
|
}
|
|
}
|
|
|
|
export interface PayslipLineForClaim {
|
|
line_id: string
|
|
salary_run_id: string
|
|
run_status: string
|
|
period_year: number
|
|
period_month: number
|
|
}
|
|
|
|
/**
|
|
* Where a claim is scheduled, if anywhere. deleteExpenseClaim consults this
|
|
* before posting the storno: on a draft run the FK cascade simply drops the
|
|
* line, but once the run has left draft its stored totals include the line,
|
|
* so the claim must stay until the line is removed from the payslip.
|
|
*/
|
|
export async function findPayslipLineForClaim(
|
|
supabase: SupabaseClient,
|
|
companyId: string,
|
|
claimId: string,
|
|
): Promise<PayslipLineForClaim | null> {
|
|
const { data, error } = await supabase
|
|
.from('salary_line_items')
|
|
.select('id, salary_run_employee:salary_run_employees(salary_run_id, salary_run:salary_runs(id, status, period_year, period_month))')
|
|
.eq('company_id', companyId)
|
|
.eq('source_expense_claim_id', claimId)
|
|
.maybeSingle()
|
|
if (error) throw new Error(`Failed to look up the claim's payslip line: ${error.message}`)
|
|
if (!data) return null
|
|
const row = data as unknown as {
|
|
id: string
|
|
salary_run_employee: {
|
|
salary_run_id: string
|
|
salary_run: { id: string; status: string; period_year: number; period_month: number } | null
|
|
} | null
|
|
}
|
|
const run = row.salary_run_employee?.salary_run
|
|
if (!run) return null
|
|
return {
|
|
line_id: row.id,
|
|
salary_run_id: run.id,
|
|
run_status: run.status,
|
|
period_year: run.period_year,
|
|
period_month: run.period_month,
|
|
}
|
|
}
|
|
|
|
interface RosterLineLike {
|
|
employee_id: string
|
|
line_items: Array<Record<string, unknown>> | null
|
|
}
|
|
|
|
interface LinkedClaimLine {
|
|
claim_id: string
|
|
employee_id: string
|
|
amount: number
|
|
}
|
|
|
|
function linkedClaimLines(roster: RosterLineLike[]): LinkedClaimLine[] {
|
|
const linked: LinkedClaimLine[] = []
|
|
for (const sre of roster) {
|
|
for (const li of sre.line_items ?? []) {
|
|
const claimId = li.source_expense_claim_id
|
|
if (typeof claimId !== 'string' || !claimId) continue
|
|
linked.push({ claim_id: claimId, employee_id: sre.employee_id, amount: Number(li.amount) || 0 })
|
|
}
|
|
}
|
|
return linked
|
|
}
|
|
|
|
/** True when any payslip line on the roster repays an expense claim. */
|
|
export function rosterHasLinkedExpenseClaims(roster: RosterLineLike[]): boolean {
|
|
return linkedClaimLines(roster).length > 0
|
|
}
|
|
|
|
export type LinkedClaimProblem = {
|
|
claim_id: string
|
|
reason: 'missing' | 'not_open' | 'employee_mismatch' | 'amount_mismatch'
|
|
}
|
|
|
|
export type LinkedClaimsCheck =
|
|
| { ok: true; claim_count: number }
|
|
| { ok: false; code: 'SALARY_RUN_EXPENSE_CLAIM_NOT_OPEN'; details: { claims: LinkedClaimProblem[] } }
|
|
|
|
/**
|
|
* Pre-booking gate: every claim the payslip repays must still be registered,
|
|
* belong to the line's employee and carry the line's amount. Run BEFORE the
|
|
* verifikat is posted: a refusal here costs nothing, while a refusal from the
|
|
* settle RPC afterwards would leave a posted 2820 debit with no claim behind
|
|
* it. Zero queries when no line is linked.
|
|
*/
|
|
export async function assertLinkedExpenseClaimsOpen(
|
|
supabase: SupabaseClient,
|
|
companyId: string,
|
|
roster: RosterLineLike[],
|
|
): Promise<LinkedClaimsCheck> {
|
|
const linked = linkedClaimLines(roster)
|
|
if (linked.length === 0) return { ok: true, claim_count: 0 }
|
|
|
|
const { data, error } = await supabase
|
|
.from('expense_claims')
|
|
.select('id, status, employee_id, amount_sek')
|
|
.eq('company_id', companyId)
|
|
.in('id', [...new Set(linked.map((l) => l.claim_id))])
|
|
if (error) throw new Error(`Failed to verify the payslip's expense claims: ${error.message}`)
|
|
const byId = new Map(
|
|
((data ?? []) as Array<{ id: string; status: string; employee_id: string | null; amount_sek: number | string }>)
|
|
.map((c) => [c.id, c] as const),
|
|
)
|
|
|
|
const problems: LinkedClaimProblem[] = []
|
|
for (const line of linked) {
|
|
const claim = byId.get(line.claim_id)
|
|
if (!claim) {
|
|
problems.push({ claim_id: line.claim_id, reason: 'missing' })
|
|
} else if (claim.status !== 'registered') {
|
|
problems.push({ claim_id: line.claim_id, reason: 'not_open' })
|
|
} else if (claim.employee_id !== line.employee_id) {
|
|
problems.push({ claim_id: line.claim_id, reason: 'employee_mismatch' })
|
|
} else if (roundOre(line.amount) !== roundOre(Number(claim.amount_sek))) {
|
|
problems.push({ claim_id: line.claim_id, reason: 'amount_mismatch' })
|
|
}
|
|
}
|
|
if (problems.length > 0) {
|
|
return { ok: false, code: 'SALARY_RUN_EXPENSE_CLAIM_NOT_OPEN', details: { claims: problems } }
|
|
}
|
|
return { ok: true, claim_count: linked.length }
|
|
}
|
|
|
|
export interface SettledExpenseClaims {
|
|
claim_count: number
|
|
already_settled: number
|
|
total_sek: number
|
|
batches: Array<{ batch_id: string; employee_id: string; total_sek: number; claim_count: number }>
|
|
}
|
|
|
|
export type SettleExpenseClaimsResult =
|
|
| { ok: true; data: SettledExpenseClaims }
|
|
| { ok: false; code: string; detail?: string }
|
|
|
|
interface SettleRpcRow {
|
|
ok: boolean
|
|
code?: string
|
|
details?: unknown
|
|
claim_count?: number
|
|
already_settled?: number
|
|
total_sek?: number | string
|
|
batches?: Array<{ batch_id: string; employee_id: string; total_sek: number | string; claim_count: number }>
|
|
}
|
|
|
|
/**
|
|
* Mark the claims a booked run repays as paid, through the
|
|
* settle_expense_claims_via_salary_run RPC (migration 20260906210300). The
|
|
* RPC locks the claims, refuses anything not open, and writes one
|
|
* expense_payout_batches row per person pointing at the salary verifikat.
|
|
* Idempotent: a retry after a partial failure counts the already settled
|
|
* claims instead of refusing them.
|
|
*/
|
|
export async function settleExpenseClaimsForBookedRun(
|
|
supabase: SupabaseClient,
|
|
args: { companyId: string; userId: string; salaryRunId: string },
|
|
): Promise<SettleExpenseClaimsResult> {
|
|
const { data, error } = await supabase.rpc('settle_expense_claims_via_salary_run', {
|
|
p_company_id: args.companyId,
|
|
p_salary_run_id: args.salaryRunId,
|
|
// Honored only for service-role callers (API-key / MCP paths); an
|
|
// authenticated caller is pinned to its own auth.uid() by the RPC.
|
|
p_user_id: args.userId,
|
|
})
|
|
if (error) {
|
|
return { ok: false, code: 'SETTLE_FAILED', detail: error.message }
|
|
}
|
|
const row = (data ?? null) as SettleRpcRow | null
|
|
if (!row) return { ok: false, code: 'SETTLE_FAILED', detail: 'empty RPC response' }
|
|
if (!row.ok) {
|
|
return {
|
|
ok: false,
|
|
code: row.code ?? 'SETTLE_FAILED',
|
|
detail: row.details ? JSON.stringify(row.details) : undefined,
|
|
}
|
|
}
|
|
return {
|
|
ok: true,
|
|
data: {
|
|
claim_count: row.claim_count ?? 0,
|
|
already_settled: row.already_settled ?? 0,
|
|
total_sek: roundOre(Number(row.total_sek ?? 0)),
|
|
batches: (row.batches ?? []).map((b) => ({
|
|
batch_id: b.batch_id,
|
|
employee_id: b.employee_id,
|
|
total_sek: roundOre(Number(b.total_sek)),
|
|
claim_count: b.claim_count,
|
|
})),
|
|
},
|
|
}
|
|
}
|