feat(dimensions): PR8 salary — employees.default_dimensions, per-employee cost lines, aggregation re-key (#869)
Employees carry a default dimensions bag and the salary booking puts each
employee's cost on their kostnadsställe/projekt
(dev_docs/dimensions_implementation_plan.md PR8):
- employees.default_dimensions (migration 20260702220000; jsonb DEFAULT
'{}' + object CHECK)
- salary-entries: the one-line-per-account aggregation is re-keyed to
account+bag — P&L cost lines (löner incl. line items + base remainder,
arbetsgivaravgifter, semesteravsättning + dess avgifter, pension, SLP)
split per employee bag while every balance-sheet/settlement leg (2710,
1930, 2731, 29xx, 2740, 2514) stays aggregated; liability credits equal
the sum of the rounded debit buckets so entries balance by construction;
dimension-less runs book byte-identically to before. Replaces the dead
SalaryRunEmployee.cost_center/project pair (never wired)
- both book routes (dashboard + v1) read the bag via the employees join —
read-at-book, so the run review shows exactly what will book
- employee form (new + edit) gets a gated Kostnadsställe/Projekt card;
run review shows per-employee dims chips; run GET + v1 employee
routes/schemas + MCP list_employees carry the field
- pre-merge audit: all salary reports (salary-journal, AGI,
avgifter-basis, vacation-liability) read salary_run_employees — not
journal lines — and every ledger consumer sums per account, so the
line split breaks nothing; SIE export + dimension P&L pick the split
up as intended
8 new engine propagation tests + book-route dims flow test.
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5
parent
755e0f7e47
commit
163fbd8222
@@ -16,6 +16,7 @@ import { getErrorMessage } from '@/lib/errors/get-error-message'
|
||||
import type { Employee } from '@/types'
|
||||
import { EmployeeBenefitsPanel } from '@/components/salary/EmployeeBenefitsPanel'
|
||||
import EmployeeTaxCard, { type EmployeeTaxValue } from '@/components/salary/EmployeeTaxCard'
|
||||
import LineDimensionFields from '@/components/dimensions/LineDimensionFields'
|
||||
|
||||
const EMPLOYMENT_LABELS: Record<string, string> = {
|
||||
employee: 'Anställd',
|
||||
@@ -39,6 +40,11 @@ export default function EmployeeDetailPage({ params }: { params: Promise<{ id: s
|
||||
const [salaryType, setSalaryType] = useState('monthly')
|
||||
const [vacationRule, setVacationRule] = useState('procentregeln')
|
||||
const [tax, setTax] = useState<EmployeeTaxValue | null>(null)
|
||||
// Default dimensions bag ({sie_dim_no: object_code}) proposed on the
|
||||
// employee's salary-cost lines at booking. The fields render only when
|
||||
// company_settings.dimensions_enabled — same UI gate as the voucher form.
|
||||
const [dimensionsEnabled, setDimensionsEnabled] = useState(false)
|
||||
const [dimensions, setDimensions] = useState<Record<string, string>>({})
|
||||
|
||||
useEffect(() => {
|
||||
async function load() {
|
||||
@@ -49,12 +55,30 @@ export default function EmployeeDetailPage({ params }: { params: Promise<{ id: s
|
||||
setEmploymentType(data.employment_type)
|
||||
setSalaryType(data.salary_type || 'monthly')
|
||||
setVacationRule(data.vacation_rule || 'procentregeln')
|
||||
setDimensions(data.default_dimensions ?? {})
|
||||
}
|
||||
setLoading(false)
|
||||
}
|
||||
load()
|
||||
}, [id])
|
||||
|
||||
useEffect(() => {
|
||||
fetch('/api/settings')
|
||||
.then((r) => r.json())
|
||||
.then(({ data }) => setDimensionsEnabled(data?.dimensions_enabled === true))
|
||||
.catch(() => {/* keep the dimension fields hidden */})
|
||||
}, [])
|
||||
|
||||
function setDimension(dimNo: string, code: string | null) {
|
||||
setDimensions((prev) => {
|
||||
const next = { ...prev }
|
||||
const value = code?.trim()
|
||||
if (value) next[dimNo] = value
|
||||
else delete next[dimNo]
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
async function handleSave(e: React.FormEvent<HTMLFormElement>) {
|
||||
e.preventDefault()
|
||||
setSaving(true)
|
||||
@@ -82,6 +106,8 @@ export default function EmployeeDetailPage({ params }: { params: Promise<{ id: s
|
||||
bank_account_number: form.get('bank_account_number') as string || undefined,
|
||||
vacation_rule: vacationRule,
|
||||
vacation_days_per_year: parseInt(form.get('vacation_days_per_year') as string) || 25,
|
||||
// Always sent — {} clears the employee's default dimensions.
|
||||
default_dimensions: dimensions,
|
||||
}
|
||||
|
||||
// Include salary field matching the current salary_type
|
||||
@@ -288,6 +314,21 @@ export default function EmployeeDetailPage({ params }: { params: Promise<{ id: s
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Default dimensions (kostnadsställe/projekt) */}
|
||||
{dimensionsEnabled && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">Kostnadsställe / Projekt (standard)</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-2">
|
||||
<LineDimensionFields dimensions={dimensions} onChange={setDimension} disabled={!canWrite} />
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Föreslås på lönekostnadsrader vid bokföring av lönekörningar.
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Tax */}
|
||||
<EmployeeTaxCard
|
||||
personnummer={employee.personnummer || ''}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import Link from 'next/link'
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
@@ -12,6 +12,7 @@ import { ArrowLeft, Save } from 'lucide-react'
|
||||
import { useToast } from '@/components/ui/use-toast'
|
||||
import { getErrorMessage } from '@/lib/errors/get-error-message'
|
||||
import EmployeeTaxCard, { type EmployeeTaxValue } from '@/components/salary/EmployeeTaxCard'
|
||||
import LineDimensionFields from '@/components/dimensions/LineDimensionFields'
|
||||
|
||||
function RequiredMark() {
|
||||
return <span className="text-destructive ml-0.5">*</span>
|
||||
@@ -25,6 +26,11 @@ export default function NewEmployeePage() {
|
||||
const [salaryType, setSalaryType] = useState('monthly')
|
||||
const [personnummer, setPersonnummer] = useState('')
|
||||
const [vacationRule, setVacationRule] = useState('procentregeln')
|
||||
// Default dimensions bag ({sie_dim_no: object_code}) proposed on the
|
||||
// employee's salary-cost lines at booking. The fields render only when
|
||||
// company_settings.dimensions_enabled — same UI gate as the voucher form.
|
||||
const [dimensionsEnabled, setDimensionsEnabled] = useState(false)
|
||||
const [dimensions, setDimensions] = useState<Record<string, string>>({})
|
||||
const [tax, setTax] = useState<EmployeeTaxValue>({
|
||||
f_skatt_status: 'a_skatt',
|
||||
is_sidoinkomst: false,
|
||||
@@ -33,6 +39,23 @@ export default function NewEmployeePage() {
|
||||
tax_municipality: '',
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
fetch('/api/settings')
|
||||
.then((r) => r.json())
|
||||
.then(({ data }) => setDimensionsEnabled(data?.dimensions_enabled === true))
|
||||
.catch(() => {/* keep the dimension fields hidden */})
|
||||
}, [])
|
||||
|
||||
function setDimension(dimNo: string, code: string | null) {
|
||||
setDimensions((prev) => {
|
||||
const next = { ...prev }
|
||||
const value = code?.trim()
|
||||
if (value) next[dimNo] = value
|
||||
else delete next[dimNo]
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
|
||||
e.preventDefault()
|
||||
setSaving(true)
|
||||
@@ -63,6 +86,8 @@ export default function NewEmployeePage() {
|
||||
bank_account_number: form.get('bank_account_number') as string || undefined,
|
||||
vacation_rule: vacationRule,
|
||||
vacation_days_per_year: parseInt(form.get('vacation_days_per_year') as string) || 25,
|
||||
// Always sent — {} means no default dimensions.
|
||||
default_dimensions: dimensions,
|
||||
}
|
||||
|
||||
const res = await fetch('/api/salary/employees', {
|
||||
@@ -232,6 +257,21 @@ export default function NewEmployeePage() {
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Default dimensions (kostnadsställe/projekt) */}
|
||||
{dimensionsEnabled && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">Kostnadsställe / Projekt (standard)</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-2">
|
||||
<LineDimensionFields dimensions={dimensions} onChange={setDimension} />
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Föreslås på lönekostnadsrader vid bokföring av lönekörningar.
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Tax */}
|
||||
<EmployeeTaxCard personnummer={personnummer} onChange={setTax} />
|
||||
|
||||
|
||||
@@ -68,6 +68,9 @@ export default function SalaryRunDetailPage({ params }: { params: Promise<{ id:
|
||||
const [actionLoading, setActionLoading] = useState<string | null>(null)
|
||||
const [addEmployeeKey, setAddEmployeeKey] = useState(0)
|
||||
const [preferredPaymentFormat, setPreferredPaymentFormat] = useState<'bg_lb' | 'pain001'>('bg_lb')
|
||||
// Gates the default-dimensions chips on the employee rows — same
|
||||
// company_settings.dimensions_enabled UI gate as the voucher form.
|
||||
const [dimensionsEnabled, setDimensionsEnabled] = useState(false)
|
||||
const [taxPayment, setTaxPayment] = useState<{
|
||||
tax_payment_file_generated_at: string | null
|
||||
tax_paid_at: string | null
|
||||
@@ -103,6 +106,7 @@ export default function SalaryRunDetailPage({ params }: { params: Promise<{ id:
|
||||
if (data?.preferred_payment_format === 'pain001' || data?.preferred_payment_format === 'bg_lb') {
|
||||
setPreferredPaymentFormat(data.preferred_payment_format)
|
||||
}
|
||||
setDimensionsEnabled(data?.dimensions_enabled === true)
|
||||
}
|
||||
setLoading(false)
|
||||
}
|
||||
@@ -509,10 +513,17 @@ export default function SalaryRunDetailPage({ params }: { params: Promise<{ id:
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{employees.map(sre => {
|
||||
const employee = (sre as SalaryRunEmployee & { employee?: { first_name: string; last_name: string; personnummer: string } }).employee
|
||||
const employee = (sre as SalaryRunEmployee & { employee?: { first_name: string; last_name: string; personnummer: string; default_dimensions?: Record<string, string> } }).employee
|
||||
const name = employee
|
||||
? `${employee.first_name} ${employee.last_name}`
|
||||
: `Anställd ${sre.employee_id.slice(0, 8)}...`
|
||||
// Compact default-dimensions bag ({sie_dim_no: object_code}),
|
||||
// dim-number order (kostnadsställe "1" before projekt "6").
|
||||
const dims = employee?.default_dimensions ?? {}
|
||||
const dimLabel = Object.keys(dims)
|
||||
.sort((a, b) => Number(a) - Number(b))
|
||||
.map(k => dims[k])
|
||||
.join(' · ')
|
||||
const taxValue = sre.tax_withheld_override ?? sre.tax_withheld
|
||||
const avgifterValue = sre.avgifter_amount_override ?? sre.avgifter_amount
|
||||
// Monthly salary is editable per run while the run is a draft.
|
||||
@@ -531,6 +542,9 @@ export default function SalaryRunDetailPage({ params }: { params: Promise<{ id:
|
||||
>
|
||||
{name}
|
||||
</Link>
|
||||
{dimensionsEnabled && dimLabel && (
|
||||
<Badge variant="secondary" className="ml-2 align-middle">{dimLabel}</Badge>
|
||||
)}
|
||||
<span className="md:hidden block text-xs text-muted-foreground font-normal mt-0.5 tabular-nums">
|
||||
{run.status === 'draft'
|
||||
? `Månadslön ${formatCurrency(sre.monthly_salary)}`
|
||||
|
||||
@@ -111,6 +111,8 @@ export async function POST(request: Request) {
|
||||
vaxa_stod_eligible: body.vaxa_stod_eligible,
|
||||
vaxa_stod_start: body.vaxa_stod_start || null,
|
||||
vaxa_stod_end: body.vaxa_stod_end || null,
|
||||
// Dimensions PR8: bag for the employee's P&L cost lines at booking.
|
||||
default_dimensions: body.default_dimensions ?? {},
|
||||
})
|
||||
.select()
|
||||
.single()
|
||||
|
||||
@@ -102,4 +102,51 @@ describe('POST /api/salary/runs/[id]/book — nollkörning', () => {
|
||||
expect(status).toBe(200)
|
||||
expect(createSalaryRunEntries).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('passes each employee default_dimensions bag from the join to the engine (PR8)', async () => {
|
||||
const { supabase, enqueueMany } = createQueuedMockSupabase()
|
||||
vi.mocked(requireAuth).mockResolvedValue({
|
||||
user: mockUser as never,
|
||||
supabase: supabase as never,
|
||||
error: null,
|
||||
})
|
||||
vi.mocked(createSalaryRunEntries).mockResolvedValue({
|
||||
salaryEntry: { id: 'je-1' },
|
||||
avgifterEntry: { id: 'je-2' },
|
||||
vacationEntry: null,
|
||||
pensionEntry: null,
|
||||
} as never)
|
||||
|
||||
enqueueMany([
|
||||
{ data: makePaidRun({ total_gross: 30000, total_tax: 7000, total_net: 23000, total_avgifter: 9426 }) },
|
||||
{
|
||||
data: [
|
||||
{
|
||||
employee_id: 'e1',
|
||||
employee: { employment_type: 'employee', default_dimensions: { '1': 'KS01' } },
|
||||
gross_salary: 30000,
|
||||
tax_withheld: 7000,
|
||||
net_salary: 23000,
|
||||
avgifter_amount: 9426,
|
||||
avgifter_rate: 0.3142,
|
||||
vacation_accrual: 0,
|
||||
vacation_accrual_avgifter: 0,
|
||||
line_items: [],
|
||||
},
|
||||
],
|
||||
}, // roster with dims from the employees join
|
||||
{ data: { id: 'run-1', status: 'booked' } }, // salary_runs update → booked
|
||||
])
|
||||
|
||||
const request = createMockRequest('/api/salary/runs/run-1/book', { method: 'POST' })
|
||||
const response = await POST(request, createMockRouteParams({ id: 'run-1' }))
|
||||
const { status } = await parseJsonResponse(response)
|
||||
|
||||
expect(status).toBe(200)
|
||||
expect(createSalaryRunEntries).toHaveBeenCalledTimes(1)
|
||||
const runInput = vi.mocked(createSalaryRunEntries).mock.calls[0][3] as {
|
||||
employees: Array<{ employee_id: string; default_dimensions?: Record<string, string> }>
|
||||
}
|
||||
expect(runInput.employees[0].default_dimensions).toEqual({ '1': 'KS01' })
|
||||
})
|
||||
})
|
||||
|
||||
@@ -33,7 +33,7 @@ export const POST = withRouteContext(
|
||||
|
||||
const { data: employees, error: empError } = await supabase
|
||||
.from('salary_run_employees')
|
||||
.select('*, employee:employees(employment_type), line_items:salary_line_items(*)')
|
||||
.select('*, employee:employees(employment_type, default_dimensions), line_items:salary_line_items(*)')
|
||||
.eq('salary_run_id', id)
|
||||
|
||||
if (empError) {
|
||||
@@ -107,6 +107,9 @@ export const POST = withRouteContext(
|
||||
avgifter_rate: sre.avgifter_rate,
|
||||
vacation_accrual: sre.vacation_accrual,
|
||||
vacation_accrual_avgifter: sre.vacation_accrual_avgifter,
|
||||
// Dimensions PR8: read-at-book from the employee row — the run
|
||||
// review shows the same live bag, so preview matches booking.
|
||||
default_dimensions: sre.employee?.default_dimensions ?? undefined,
|
||||
line_items: (sre.line_items || []).map((li: Record<string, unknown>) => ({
|
||||
item_type: li.item_type as string,
|
||||
amount: li.amount as number,
|
||||
|
||||
@@ -25,7 +25,7 @@ export const GET = withRouteContext<{ params: Promise<{ id: string }> }>(
|
||||
// Load employees with line items
|
||||
const { data: employees } = await supabase
|
||||
.from('salary_run_employees')
|
||||
.select('*, employee:employees(id, first_name, last_name, personnummer, personnummer_last4, employment_type), line_items:salary_line_items(*)')
|
||||
.select('*, employee:employees(id, first_name, last_name, personnummer, personnummer_last4, employment_type, default_dimensions), line_items:salary_line_items(*)')
|
||||
.eq('salary_run_id', id)
|
||||
.order('created_at')
|
||||
|
||||
|
||||
@@ -61,13 +61,15 @@ const EmployeeDetail = z.object({
|
||||
vaxa_stod_eligible: z.boolean(),
|
||||
vaxa_stod_start: z.string().nullable(),
|
||||
vaxa_stod_end: z.string().nullable(),
|
||||
// Dimensions PR8: bag applied to the employee's P&L cost lines at booking.
|
||||
default_dimensions: z.record(z.string(), z.string()),
|
||||
is_active: z.boolean(),
|
||||
created_at: z.string(),
|
||||
updated_at: z.string(),
|
||||
})
|
||||
|
||||
const EMPLOYEE_DETAIL_COLUMNS =
|
||||
'id, first_name, last_name, personnummer, employment_type, employment_start, employment_end, employment_degree, salary_type, monthly_salary, hourly_rate, tax_table_number, tax_column, tax_municipality, is_sidoinkomst, f_skatt_status, clearing_number, bank_account_number, vacation_rule, vacation_days_per_year, semestertillagg_rate, email, phone, address_line1, postal_code, city, vaxa_stod_eligible, vaxa_stod_start, vaxa_stod_end, is_active, created_at, updated_at'
|
||||
'id, first_name, last_name, personnummer, employment_type, employment_start, employment_end, employment_degree, salary_type, monthly_salary, hourly_rate, tax_table_number, tax_column, tax_municipality, is_sidoinkomst, f_skatt_status, clearing_number, bank_account_number, vacation_rule, vacation_days_per_year, semestertillagg_rate, email, phone, address_line1, postal_code, city, vaxa_stod_eligible, vaxa_stod_start, vaxa_stod_end, default_dimensions, is_active, created_at, updated_at'
|
||||
|
||||
/**
|
||||
* Shape returned by PATCH (success + dry-run preview) and by no-change PATCH.
|
||||
|
||||
@@ -429,6 +429,8 @@ export const POST = withApiV1<{ params: Promise<{ companyId: string }> }>(
|
||||
vaxa_stod_eligible: body.vaxa_stod_eligible,
|
||||
vaxa_stod_start: body.vaxa_stod_start ?? null,
|
||||
vaxa_stod_end: body.vaxa_stod_end ?? null,
|
||||
// Dimensions PR8: bag for the employee's P&L cost lines at booking.
|
||||
default_dimensions: body.default_dimensions ?? {},
|
||||
})
|
||||
.select(EMPLOYEE_RESPONSE_COLUMNS)
|
||||
.single()
|
||||
|
||||
@@ -154,7 +154,7 @@ export const POST = withApiV1<{ params: Promise<{ companyId: string; id: string
|
||||
// 3. Load run + employees + line items for the engine.
|
||||
const { data: employees, error: empErr } = await ctx.supabase
|
||||
.from('salary_run_employees')
|
||||
.select('*, employee:employees(employment_type), line_items:salary_line_items(*)')
|
||||
.select('*, employee:employees(employment_type, default_dimensions), line_items:salary_line_items(*)')
|
||||
.eq('salary_run_id', salaryRunId)
|
||||
if (empErr) {
|
||||
return v1ErrorResponse(empErr, ctx.log, { requestId: ctx.requestId })
|
||||
@@ -200,7 +200,7 @@ export const POST = withApiV1<{ params: Promise<{ companyId: string; id: string
|
||||
// 4. Engine call. Strict-mode: any throw aborts before status flip.
|
||||
type EmpRow = {
|
||||
employee_id: string
|
||||
employee: { employment_type: string } | null
|
||||
employee: { employment_type: string; default_dimensions?: Record<string, string> } | null
|
||||
gross_salary: number
|
||||
tax_withheld: number
|
||||
net_salary: number
|
||||
@@ -242,6 +242,8 @@ export const POST = withApiV1<{ params: Promise<{ companyId: string; id: string
|
||||
avgifter_rate: sre.avgifter_rate,
|
||||
vacation_accrual: sre.vacation_accrual,
|
||||
vacation_accrual_avgifter: sre.vacation_accrual_avgifter,
|
||||
// Dimensions PR8: read-at-book from the employee row.
|
||||
default_dimensions: sre.employee?.default_dimensions ?? undefined,
|
||||
line_items: (sre.line_items || []).map((li) => ({
|
||||
item_type: li.item_type,
|
||||
amount: li.amount,
|
||||
|
||||
@@ -8205,7 +8205,7 @@ export const tools: McpTool[] = [
|
||||
const activeOnly = args.active_only !== false
|
||||
let query = supabase
|
||||
.from('employees')
|
||||
.select('id, first_name, last_name, personnummer, personnummer_last4, employment_type, monthly_salary, hourly_rate, employment_degree, tax_table_number, tax_column, salary_type, is_active')
|
||||
.select('id, first_name, last_name, personnummer, personnummer_last4, employment_type, monthly_salary, hourly_rate, employment_degree, tax_table_number, tax_column, salary_type, default_dimensions, is_active')
|
||||
.eq('company_id', companyId)
|
||||
if (activeOnly) query = query.eq('is_active', true)
|
||||
const { data, error } = await query.order('last_name')
|
||||
|
||||
@@ -1682,6 +1682,9 @@ const EmployeeSchemaBase = z.object({
|
||||
vaxa_stod_eligible: z.boolean().default(false),
|
||||
vaxa_stod_start: isoDate.optional(),
|
||||
vaxa_stod_end: isoDate.optional(),
|
||||
// Dimensions PR8: bag applied to the employee's P&L cost lines when a
|
||||
// salary run is booked. {} clears (the UI always sends the field).
|
||||
default_dimensions: DimensionsBagSchema.optional(),
|
||||
})
|
||||
|
||||
export const CreateEmployeeSchema = EmployeeSchemaBase.superRefine((data, ctx) => {
|
||||
|
||||
@@ -0,0 +1,287 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import type { CreateJournalEntryInput, CreateJournalEntryLineInput } from '@/types'
|
||||
|
||||
// Capture pattern: mock the engine and assert on the CreateJournalEntryInput
|
||||
// each salary sub-entry builder produces (same approach as
|
||||
// lib/bookkeeping/__tests__/invoice-entries.test.ts).
|
||||
vi.mock('@/lib/bookkeeping/engine', () => ({
|
||||
createJournalEntry: vi.fn(async (_s: unknown, _c: string, _u: string, input: CreateJournalEntryInput) => ({
|
||||
id: `je-${input.description}`,
|
||||
...input,
|
||||
})),
|
||||
findFiscalPeriod: vi.fn(async () => 'fp-1'),
|
||||
}))
|
||||
|
||||
import { createJournalEntry } from '@/lib/bookkeeping/engine'
|
||||
import { createSalaryRunEntries } from '../salary-entries'
|
||||
|
||||
const mockedCreateEntry = vi.mocked(createJournalEntry)
|
||||
|
||||
// Supabase mock only needs the chart_of_accounts existence check in
|
||||
// ensureSalaryAccountsExist — pretend every account already exists.
|
||||
function makeSupabase() {
|
||||
return {
|
||||
from: vi.fn(() => ({
|
||||
select: vi.fn(() => ({
|
||||
eq: vi.fn(() => ({
|
||||
in: vi.fn(async (_col: string, accounts: string[]) => ({
|
||||
data: accounts.map((account_number) => ({ account_number })),
|
||||
error: null,
|
||||
})),
|
||||
})),
|
||||
})),
|
||||
})),
|
||||
} as never
|
||||
}
|
||||
|
||||
interface EmployeeOverrides {
|
||||
employee_id?: string
|
||||
employment_type?: string
|
||||
gross_salary?: number
|
||||
tax_withheld?: number
|
||||
net_salary?: number
|
||||
avgifter_amount?: number
|
||||
vacation_accrual?: number
|
||||
vacation_accrual_avgifter?: number
|
||||
default_dimensions?: Record<string, string>
|
||||
pension_contribution?: number
|
||||
pension_slp?: number
|
||||
line_items?: Array<{
|
||||
item_type: string
|
||||
amount: number
|
||||
account_number: string | null
|
||||
is_net_deduction: boolean
|
||||
is_gross_deduction: boolean
|
||||
}>
|
||||
}
|
||||
|
||||
function makeEmployee(overrides: EmployeeOverrides = {}) {
|
||||
return {
|
||||
employee_id: 'emp-1',
|
||||
employment_type: 'employee',
|
||||
gross_salary: 30000,
|
||||
tax_withheld: 7000,
|
||||
net_salary: 23000,
|
||||
avgifter_amount: 9426,
|
||||
avgifter_rate: 0.3142,
|
||||
vacation_accrual: 0,
|
||||
vacation_accrual_avgifter: 0,
|
||||
line_items: [],
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
function makeRun(employees: ReturnType<typeof makeEmployee>[]) {
|
||||
return {
|
||||
id: 'run-1',
|
||||
period_year: 2026,
|
||||
period_month: 6,
|
||||
payment_date: '2026-06-25',
|
||||
voucher_series: 'L',
|
||||
total_gross: employees.reduce((s, e) => s + e.gross_salary, 0),
|
||||
total_tax: employees.reduce((s, e) => s + e.tax_withheld, 0),
|
||||
total_net: employees.reduce((s, e) => s + e.net_salary, 0),
|
||||
total_avgifter: employees.reduce((s, e) => s + e.avgifter_amount, 0),
|
||||
total_vacation_accrual: employees.reduce((s, e) => s + e.vacation_accrual, 0),
|
||||
employees,
|
||||
}
|
||||
}
|
||||
|
||||
function entryByDescription(pattern: string): CreateJournalEntryInput {
|
||||
const call = mockedCreateEntry.mock.calls.find((c) => c[3].description.includes(pattern))
|
||||
if (!call) throw new Error(`no entry matching "${pattern}"`)
|
||||
return call[3]
|
||||
}
|
||||
|
||||
function assertBalanced(input: CreateJournalEntryInput) {
|
||||
const debit = input.lines.reduce((s, l) => s + l.debit_amount, 0)
|
||||
const credit = input.lines.reduce((s, l) => s + l.credit_amount, 0)
|
||||
expect(Math.abs(debit - credit)).toBeLessThan(0.005)
|
||||
}
|
||||
|
||||
function linesOn(input: CreateJournalEntryInput, account: string): CreateJournalEntryLineInput[] {
|
||||
return input.lines.filter((l) => l.account_number === account)
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
mockedCreateEntry.mockClear()
|
||||
})
|
||||
|
||||
describe('salary entries — dimensions propagation (PR8)', () => {
|
||||
it('splits the salary expense per employee bag; tax and bank legs stay untagged', async () => {
|
||||
const run = makeRun([
|
||||
makeEmployee({ employee_id: 'a', default_dimensions: { '1': 'KS01' } }),
|
||||
makeEmployee({ employee_id: 'b', default_dimensions: { '1': 'KS02', '6': 'P001' } }),
|
||||
makeEmployee({ employee_id: 'c' }), // untagged
|
||||
])
|
||||
|
||||
await createSalaryRunEntries(makeSupabase(), 'company-1', 'user-1', run)
|
||||
const salary = entryByDescription('Lön 2026-06')
|
||||
|
||||
const salaryLines = linesOn(salary, '7210')
|
||||
expect(salaryLines).toHaveLength(3)
|
||||
expect(salaryLines.map((l) => l.dimensions)).toEqual([
|
||||
{ '1': 'KS01' },
|
||||
{ '1': 'KS02', '6': 'P001' },
|
||||
undefined,
|
||||
])
|
||||
for (const line of salaryLines) expect(line.debit_amount).toBe(30000)
|
||||
|
||||
const taxLine = linesOn(salary, '2710')[0]
|
||||
expect(taxLine.credit_amount).toBe(21000)
|
||||
expect(taxLine.dimensions).toBeUndefined()
|
||||
const bankLine = linesOn(salary, '1930')[0]
|
||||
expect(bankLine.credit_amount).toBe(69000)
|
||||
expect(bankLine.dimensions).toBeUndefined()
|
||||
|
||||
assertBalanced(salary)
|
||||
})
|
||||
|
||||
it('employees sharing a bag aggregate onto one line (and a dimension-less run books like before)', async () => {
|
||||
const run = makeRun([
|
||||
makeEmployee({ employee_id: 'a', default_dimensions: { '1': 'KS01' } }),
|
||||
makeEmployee({ employee_id: 'b', default_dimensions: { '1': 'KS01' } }),
|
||||
])
|
||||
await createSalaryRunEntries(makeSupabase(), 'company-1', 'user-1', run)
|
||||
const salary = entryByDescription('Lön 2026-06')
|
||||
const salaryLines = linesOn(salary, '7210')
|
||||
expect(salaryLines).toHaveLength(1)
|
||||
expect(salaryLines[0].debit_amount).toBe(60000)
|
||||
expect(salaryLines[0].dimensions).toEqual({ '1': 'KS01' })
|
||||
|
||||
mockedCreateEntry.mockClear()
|
||||
const bagless = makeRun([makeEmployee({ employee_id: 'a' }), makeEmployee({ employee_id: 'b' })])
|
||||
await createSalaryRunEntries(makeSupabase(), 'company-1', 'user-1', bagless)
|
||||
const legacy = entryByDescription('Lön 2026-06')
|
||||
const legacyLines = linesOn(legacy, '7210')
|
||||
expect(legacyLines).toHaveLength(1)
|
||||
expect(legacyLines[0].debit_amount).toBe(60000)
|
||||
expect(legacyLines[0].dimensions).toBeUndefined()
|
||||
})
|
||||
|
||||
it('line items and the base remainder follow the employee bag', async () => {
|
||||
const run = makeRun([
|
||||
makeEmployee({
|
||||
employee_id: 'a',
|
||||
gross_salary: 32000,
|
||||
default_dimensions: { '6': 'P001' },
|
||||
line_items: [
|
||||
{ item_type: 'overtime', amount: 2000, account_number: '7281', is_net_deduction: false, is_gross_deduction: false },
|
||||
],
|
||||
}),
|
||||
])
|
||||
await createSalaryRunEntries(makeSupabase(), 'company-1', 'user-1', run)
|
||||
const salary = entryByDescription('Lön 2026-06')
|
||||
|
||||
const overtime = linesOn(salary, '7281')[0]
|
||||
expect(overtime.debit_amount).toBe(2000)
|
||||
expect(overtime.dimensions).toEqual({ '6': 'P001' })
|
||||
// Remainder (32000 - 2000) books to 7210 in the same bag.
|
||||
const base = linesOn(salary, '7210')[0]
|
||||
expect(base.debit_amount).toBe(30000)
|
||||
expect(base.dimensions).toEqual({ '6': 'P001' })
|
||||
})
|
||||
|
||||
it('splits avgifter per bag with a single aggregated 2731 liability', async () => {
|
||||
const run = makeRun([
|
||||
makeEmployee({ employee_id: 'a', avgifter_amount: 9426.505, default_dimensions: { '1': 'KS01' } }),
|
||||
makeEmployee({ employee_id: 'b', avgifter_amount: 9426.505, default_dimensions: { '1': 'KS02' } }),
|
||||
])
|
||||
await createSalaryRunEntries(makeSupabase(), 'company-1', 'user-1', run)
|
||||
const avgifter = entryByDescription('Arbetsgivaravgifter')
|
||||
|
||||
const expense = linesOn(avgifter, '7510')
|
||||
expect(expense).toHaveLength(2)
|
||||
expect(expense.map((l) => l.dimensions)).toEqual([{ '1': 'KS01' }, { '1': 'KS02' }])
|
||||
|
||||
const liability = linesOn(avgifter, '2731')
|
||||
expect(liability).toHaveLength(1)
|
||||
expect(liability[0].dimensions).toBeUndefined()
|
||||
// Balance by construction: credit equals the sum of the ROUNDED debits,
|
||||
// even when the partition rounds differently from the raw total.
|
||||
expect(liability[0].credit_amount).toBe(
|
||||
Math.round(expense.reduce((s, l) => s + l.debit_amount, 0) * 100) / 100,
|
||||
)
|
||||
assertBalanced(avgifter)
|
||||
})
|
||||
|
||||
it('keeps the legacy zero-avgifter shape (single untagged debit)', async () => {
|
||||
const run = makeRun([
|
||||
makeEmployee({ employee_id: 'a', avgifter_amount: 0, gross_salary: 1000, tax_withheld: 0, net_salary: 1000 }),
|
||||
])
|
||||
await createSalaryRunEntries(makeSupabase(), 'company-1', 'user-1', run)
|
||||
const avgifter = entryByDescription('Arbetsgivaravgifter')
|
||||
const expense = linesOn(avgifter, '7510')
|
||||
expect(expense).toHaveLength(1)
|
||||
expect(expense[0].debit_amount).toBe(0)
|
||||
expect(expense[0].dimensions).toBeUndefined()
|
||||
})
|
||||
|
||||
it('splits vacation accrual + its avgifter per bag; liabilities stay aggregated', async () => {
|
||||
const run = makeRun([
|
||||
makeEmployee({
|
||||
employee_id: 'a',
|
||||
vacation_accrual: 3600,
|
||||
vacation_accrual_avgifter: 1131.12,
|
||||
default_dimensions: { '1': 'KS01' },
|
||||
}),
|
||||
makeEmployee({
|
||||
employee_id: 'b',
|
||||
vacation_accrual: 3600,
|
||||
vacation_accrual_avgifter: 1131.12,
|
||||
default_dimensions: { '6': 'P001' },
|
||||
}),
|
||||
])
|
||||
await createSalaryRunEntries(makeSupabase(), 'company-1', 'user-1', run)
|
||||
const vacation = entryByDescription('Semesteravsättning')
|
||||
|
||||
expect(linesOn(vacation, '7290')).toHaveLength(2)
|
||||
expect(linesOn(vacation, '7290').map((l) => l.dimensions)).toEqual([{ '1': 'KS01' }, { '6': 'P001' }])
|
||||
expect(linesOn(vacation, '2920')).toHaveLength(1)
|
||||
expect(linesOn(vacation, '2920')[0].dimensions).toBeUndefined()
|
||||
expect(linesOn(vacation, '2920')[0].credit_amount).toBe(7200)
|
||||
|
||||
expect(linesOn(vacation, '7519')).toHaveLength(2)
|
||||
expect(linesOn(vacation, '2940')).toHaveLength(1)
|
||||
expect(linesOn(vacation, '2940')[0].credit_amount).toBe(2262.24)
|
||||
assertBalanced(vacation)
|
||||
})
|
||||
|
||||
it('splits pension + SLP per bag; liabilities stay aggregated', async () => {
|
||||
const run = makeRun([
|
||||
makeEmployee({
|
||||
employee_id: 'a',
|
||||
pension_contribution: 2116,
|
||||
pension_slp: 513.34,
|
||||
default_dimensions: { '1': 'KS01' },
|
||||
}),
|
||||
makeEmployee({
|
||||
employee_id: 'b',
|
||||
pension_contribution: 1058,
|
||||
pension_slp: 256.67,
|
||||
}),
|
||||
])
|
||||
await createSalaryRunEntries(makeSupabase(), 'company-1', 'user-1', run)
|
||||
const pension = entryByDescription('Pensionsavsättning')
|
||||
|
||||
const pensionLines = linesOn(pension, '7410')
|
||||
expect(pensionLines).toHaveLength(2)
|
||||
expect(pensionLines.map((l) => l.dimensions)).toEqual([{ '1': 'KS01' }, undefined])
|
||||
expect(linesOn(pension, '2740')[0].credit_amount).toBe(3174)
|
||||
expect(linesOn(pension, '2740')[0].dimensions).toBeUndefined()
|
||||
|
||||
const slpLines = linesOn(pension, '7533')
|
||||
expect(slpLines).toHaveLength(2)
|
||||
expect(linesOn(pension, '2514')[0].credit_amount).toBe(770.01)
|
||||
assertBalanced(pension)
|
||||
})
|
||||
|
||||
it('rejects an invalid bag (coerce gate) rather than booking junk keys', async () => {
|
||||
const run = makeRun([
|
||||
makeEmployee({ employee_id: 'a', default_dimensions: { '0': 'BAD' } as Record<string, string> }),
|
||||
])
|
||||
await createSalaryRunEntries(makeSupabase(), 'company-1', 'user-1', run)
|
||||
const salary = entryByDescription('Lön 2026-06')
|
||||
expect(linesOn(salary, '7210')[0].dimensions).toBeUndefined()
|
||||
})
|
||||
})
|
||||
+122
-43
@@ -1,6 +1,12 @@
|
||||
import { createJournalEntry, findFiscalPeriod } from '@/lib/bookkeeping/engine'
|
||||
import { getBASReference } from '@/lib/bookkeeping/bas-reference'
|
||||
import {
|
||||
coerceDimensionsBag,
|
||||
dimensionsBagKey,
|
||||
type LineDimensions,
|
||||
} from '@/lib/bookkeeping/dimension-resolver'
|
||||
import { createLogger } from '@/lib/logger'
|
||||
import { roundOre } from '@/lib/money'
|
||||
import { SALARY_ACCOUNTS, getLineItemAccount } from './account-mapping'
|
||||
import type { SupabaseClient } from '@supabase/supabase-js'
|
||||
import type {
|
||||
@@ -21,8 +27,14 @@ interface SalaryRunEmployee {
|
||||
avgifter_rate: number
|
||||
vacation_accrual: number
|
||||
vacation_accrual_avgifter: number
|
||||
cost_center?: string
|
||||
project?: string
|
||||
// Dimensions PR8: the employee's default bag ({sie_dim_no: code}), read
|
||||
// from employees.default_dimensions by the book routes. P&L cost lines
|
||||
// (löner, avgifter, semester, pension, SLP) split per bag; the
|
||||
// balance-sheet/settlement legs (2710, 1930, 2731, 29xx, 2740, 2514)
|
||||
// stay aggregated — a liability toward Skatteverket or the bank has no
|
||||
// per-employee dimension. Replaces the never-wired cost_center/project
|
||||
// pair that predated the JSONB substrate.
|
||||
default_dimensions?: Record<string, string>
|
||||
line_items: Array<{
|
||||
item_type: string
|
||||
amount: number
|
||||
@@ -133,11 +145,29 @@ async function createSalaryEntry(
|
||||
): Promise<JournalEntry> {
|
||||
const lines: CreateJournalEntryLineInput[] = []
|
||||
|
||||
// Aggregate salary expenses by account
|
||||
const expenseByAccount = new Map<string, number>()
|
||||
// Aggregate salary expenses by (account, dimensions) — dimensions PR8. The
|
||||
// employee's bag is part of the aggregation identity, so two employees on
|
||||
// the same account but different kostnadsställen produce separate lines
|
||||
// instead of collapsing (the dead cost_center/project fields never did
|
||||
// this). Dimension-less runs collapse to one bucket per account and book
|
||||
// byte-identically to before.
|
||||
interface ExpenseBucket {
|
||||
account: string
|
||||
dimensions?: LineDimensions
|
||||
amount: number
|
||||
}
|
||||
const expenseBuckets = new Map<string, ExpenseBucket>()
|
||||
const addExpense = (account: string, dimensions: LineDimensions | undefined, amount: number) => {
|
||||
const key = `${account}\u0000${dimensionsBagKey(dimensions)}`
|
||||
const bucket = expenseBuckets.get(key) ?? { account, dimensions, amount: 0 }
|
||||
bucket.amount += amount
|
||||
expenseBuckets.set(key, bucket)
|
||||
}
|
||||
|
||||
for (const emp of run.employees) {
|
||||
// Base salary and additions go to the employee-type account
|
||||
const salaryAccount = getEmployeeSalaryAccount(emp.employment_type)
|
||||
const dimensions = coerceDimensionsBag(emp.default_dimensions)
|
||||
|
||||
// Add salary line items that are cash expenses
|
||||
// Förmånsvärden (benefits) are excluded — they affect the tax base but
|
||||
@@ -148,8 +178,7 @@ async function createSalaryEntry(
|
||||
if (li.is_net_deduction || li.is_gross_deduction) continue
|
||||
if (BENEFIT_TYPES.includes(li.item_type)) continue // No cash flow for förmånsvärden
|
||||
const account = li.account_number || getLineItemAccount(li.item_type as never, emp.employment_type)
|
||||
const current = expenseByAccount.get(account) || 0
|
||||
expenseByAccount.set(account, current + li.amount)
|
||||
addExpense(account, dimensions, li.amount)
|
||||
lineItemTotal += li.amount
|
||||
}
|
||||
|
||||
@@ -160,28 +189,29 @@ async function createSalaryEntry(
|
||||
// base-salary line item would fail the check_journal_entry_balance() trigger.
|
||||
const baseRemainder = Math.round((emp.gross_salary - lineItemTotal) * 100) / 100
|
||||
if (baseRemainder !== 0) {
|
||||
const current = expenseByAccount.get(salaryAccount) || 0
|
||||
expenseByAccount.set(salaryAccount, current + baseRemainder)
|
||||
addExpense(salaryAccount, dimensions, baseRemainder)
|
||||
}
|
||||
}
|
||||
|
||||
// Debit: Salary expense accounts
|
||||
for (const [account, amount] of expenseByAccount) {
|
||||
if (amount === 0) continue
|
||||
if (amount > 0) {
|
||||
// Debit: Salary expense accounts (one line per account+dimensions bucket)
|
||||
for (const bucket of expenseBuckets.values()) {
|
||||
if (bucket.amount === 0) continue
|
||||
if (bucket.amount > 0) {
|
||||
lines.push({
|
||||
account_number: account,
|
||||
debit_amount: Math.round(amount * 100) / 100,
|
||||
account_number: bucket.account,
|
||||
debit_amount: roundOre(bucket.amount),
|
||||
credit_amount: 0,
|
||||
line_description: `${desc} — ${accountLabel(account)}`,
|
||||
line_description: `${desc} — ${accountLabel(bucket.account)}`,
|
||||
dimensions: bucket.dimensions,
|
||||
})
|
||||
} else {
|
||||
// Negative amounts (deductions) become credits
|
||||
lines.push({
|
||||
account_number: account,
|
||||
account_number: bucket.account,
|
||||
debit_amount: 0,
|
||||
credit_amount: Math.round(Math.abs(amount) * 100) / 100,
|
||||
line_description: `${desc} — ${accountLabel(account)}`,
|
||||
credit_amount: roundOre(Math.abs(bucket.amount)),
|
||||
line_description: `${desc} — ${accountLabel(bucket.account)}`,
|
||||
dimensions: bucket.dimensions,
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -222,11 +252,38 @@ async function createSalaryEntry(
|
||||
return createJournalEntry(supabase, companyId, userId, input)
|
||||
}
|
||||
|
||||
/**
|
||||
* Bucket a per-employee amount by the employee's dimensions bag — dimensions
|
||||
* PR8. Used for the P&L cost side of the avgifter/vacation/pension entries:
|
||||
* one debit line per distinct bag, while the liability credit stays a single
|
||||
* aggregated line. Zero amounts are skipped; each bucket is rounded and the
|
||||
* caller credits the SUM OF ROUNDED buckets so the entry balances by
|
||||
* construction regardless of how the total partitions.
|
||||
*/
|
||||
function bucketByEmployeeDimensions(
|
||||
employees: SalaryRunEmployee[],
|
||||
amountOf: (emp: SalaryRunEmployee) => number
|
||||
): Array<{ dimensions?: LineDimensions; amount: number }> {
|
||||
const buckets = new Map<string, { dimensions?: LineDimensions; amount: number }>()
|
||||
for (const emp of employees) {
|
||||
const amount = amountOf(emp)
|
||||
if (!amount) continue
|
||||
const dimensions = coerceDimensionsBag(emp.default_dimensions)
|
||||
const key = dimensionsBagKey(dimensions)
|
||||
const bucket = buckets.get(key) ?? { dimensions, amount: 0 }
|
||||
bucket.amount += amount
|
||||
buckets.set(key, bucket)
|
||||
}
|
||||
return [...buckets.values()]
|
||||
.map((b) => ({ ...b, amount: roundOre(b.amount) }))
|
||||
.filter((b) => b.amount !== 0)
|
||||
}
|
||||
|
||||
/**
|
||||
* Entry 2: Arbetsgivaravgifter.
|
||||
*
|
||||
* Debit: 7510 Lagstadgade sociala avgifter
|
||||
* Credit: 2731 Avräkning sociala avgifter
|
||||
* Debit: 7510 Lagstadgade sociala avgifter (per dimensions bucket)
|
||||
* Credit: 2731 Avräkning sociala avgifter (single aggregated liability)
|
||||
*/
|
||||
async function createAvgifterEntry(
|
||||
supabase: SupabaseClient,
|
||||
@@ -236,16 +293,20 @@ async function createAvgifterEntry(
|
||||
fiscalPeriodId: string,
|
||||
desc: string
|
||||
): Promise<JournalEntry> {
|
||||
const totalAvgifter = run.employees.reduce((sum, e) => sum + e.avgifter_amount, 0)
|
||||
const roundedAvgifter = Math.round(totalAvgifter * 100) / 100
|
||||
const dimBuckets = bucketByEmployeeDimensions(run.employees, (e) => e.avgifter_amount)
|
||||
// Legacy shape parity: a run whose avgifter sum to zero still emits the
|
||||
// single untagged debit line, exactly as before the dimension split.
|
||||
const buckets = dimBuckets.length > 0 ? dimBuckets : [{ dimensions: undefined, amount: 0 }]
|
||||
const roundedAvgifter = roundOre(buckets.reduce((sum, b) => sum + b.amount, 0))
|
||||
|
||||
const lines: CreateJournalEntryLineInput[] = [
|
||||
{
|
||||
...buckets.map((bucket): CreateJournalEntryLineInput => ({
|
||||
account_number: SALARY_ACCOUNTS.AVGIFTER_EXPENSE,
|
||||
debit_amount: roundedAvgifter,
|
||||
debit_amount: bucket.amount,
|
||||
credit_amount: 0,
|
||||
line_description: `${desc} — Arbetsgivaravgifter`,
|
||||
},
|
||||
dimensions: bucket.dimensions,
|
||||
})),
|
||||
{
|
||||
account_number: SALARY_ACCOUNTS.AVGIFTER_LIABILITY,
|
||||
debit_amount: 0,
|
||||
@@ -292,34 +353,43 @@ async function createVacationEntry(
|
||||
const lines: CreateJournalEntryLineInput[] = []
|
||||
|
||||
if (roundedVacation > 0) {
|
||||
// Dimensions PR8: cost per bag, liability aggregated. The credit equals
|
||||
// the sum of the rounded debit buckets so the entry balances by
|
||||
// construction (may differ from round(total) by an öre when partitioned).
|
||||
const buckets = bucketByEmployeeDimensions(run.employees, (e) => e.vacation_accrual)
|
||||
const creditTotal = roundOre(buckets.reduce((sum, b) => sum + b.amount, 0))
|
||||
lines.push(
|
||||
{
|
||||
...buckets.map((bucket): CreateJournalEntryLineInput => ({
|
||||
account_number: SALARY_ACCOUNTS.VACATION_ACCRUAL_EXPENSE,
|
||||
debit_amount: roundedVacation,
|
||||
debit_amount: bucket.amount,
|
||||
credit_amount: 0,
|
||||
line_description: `${desc} — Semesteravsättning`,
|
||||
},
|
||||
dimensions: bucket.dimensions,
|
||||
})),
|
||||
{
|
||||
account_number: SALARY_ACCOUNTS.VACATION_ACCRUAL_LIABILITY,
|
||||
debit_amount: 0,
|
||||
credit_amount: roundedVacation,
|
||||
credit_amount: creditTotal,
|
||||
line_description: `${desc} — Semesteravsättning`,
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
if (roundedAvgifter > 0) {
|
||||
const buckets = bucketByEmployeeDimensions(run.employees, (e) => e.vacation_accrual_avgifter)
|
||||
const creditTotal = roundOre(buckets.reduce((sum, b) => sum + b.amount, 0))
|
||||
lines.push(
|
||||
{
|
||||
...buckets.map((bucket): CreateJournalEntryLineInput => ({
|
||||
account_number: SALARY_ACCOUNTS.VACATION_AVGIFTER_EXPENSE,
|
||||
debit_amount: roundedAvgifter,
|
||||
debit_amount: bucket.amount,
|
||||
credit_amount: 0,
|
||||
line_description: `${desc} — Sociala avgifter på semester`,
|
||||
},
|
||||
dimensions: bucket.dimensions,
|
||||
})),
|
||||
{
|
||||
account_number: SALARY_ACCOUNTS.VACATION_AVGIFTER_LIABILITY,
|
||||
debit_amount: 0,
|
||||
credit_amount: roundedAvgifter,
|
||||
credit_amount: creditTotal,
|
||||
line_description: `${desc} — Sociala avgifter på semester`,
|
||||
}
|
||||
)
|
||||
@@ -359,36 +429,45 @@ async function createPensionEntry(
|
||||
totalPension: number,
|
||||
totalSlp: number
|
||||
): Promise<JournalEntry> {
|
||||
const roundedPension = Math.round(totalPension * 100) / 100
|
||||
const roundedSlp = Math.round(totalSlp * 100) / 100
|
||||
|
||||
// Dimensions PR8: pension + SLP cost per bag, liabilities aggregated.
|
||||
// Credits equal the sum of the rounded debit buckets (balance by
|
||||
// construction). The caller gates on totalPension > 0.
|
||||
const pensionBuckets = bucketByEmployeeDimensions(run.employees, (e) => e.pension_contribution || 0)
|
||||
const pensionCredit = roundOre(pensionBuckets.reduce((sum, b) => sum + b.amount, 0))
|
||||
|
||||
const lines: CreateJournalEntryLineInput[] = [
|
||||
{
|
||||
...pensionBuckets.map((bucket): CreateJournalEntryLineInput => ({
|
||||
account_number: SALARY_ACCOUNTS.PENSION_EXPENSE,
|
||||
debit_amount: roundedPension,
|
||||
debit_amount: bucket.amount,
|
||||
credit_amount: 0,
|
||||
line_description: `${desc} — Pensionsförsäkringspremier`,
|
||||
},
|
||||
dimensions: bucket.dimensions,
|
||||
})),
|
||||
{
|
||||
account_number: SALARY_ACCOUNTS.PENSION_LIABILITY,
|
||||
debit_amount: 0,
|
||||
credit_amount: roundedPension,
|
||||
credit_amount: pensionCredit,
|
||||
line_description: `${desc} — Pensionsförsäkringspremier`,
|
||||
},
|
||||
]
|
||||
|
||||
if (roundedSlp > 0) {
|
||||
const slpBuckets = bucketByEmployeeDimensions(run.employees, (e) => e.pension_slp || 0)
|
||||
const slpCredit = roundOre(slpBuckets.reduce((sum, b) => sum + b.amount, 0))
|
||||
lines.push(
|
||||
{
|
||||
...slpBuckets.map((bucket): CreateJournalEntryLineInput => ({
|
||||
account_number: SALARY_ACCOUNTS.SLP_EXPENSE,
|
||||
debit_amount: roundedSlp,
|
||||
debit_amount: bucket.amount,
|
||||
credit_amount: 0,
|
||||
line_description: `${desc} — Särskild löneskatt 24,26%`,
|
||||
},
|
||||
dimensions: bucket.dimensions,
|
||||
})),
|
||||
{
|
||||
account_number: SALARY_ACCOUNTS.SLP_LIABILITY,
|
||||
debit_amount: 0,
|
||||
credit_amount: roundedSlp,
|
||||
credit_amount: slpCredit,
|
||||
line_description: `${desc} — Särskild löneskatt 24,26%`,
|
||||
}
|
||||
)
|
||||
@@ -404,7 +483,7 @@ async function createPensionEntry(
|
||||
lines,
|
||||
}
|
||||
|
||||
log.info(`Creating pension entry for ${desc}: ${roundedPension} SEK pension + ${roundedSlp} SEK SLP`)
|
||||
log.info(`Creating pension entry for ${desc}: ${pensionCredit} SEK pension + ${roundedSlp} SEK SLP`)
|
||||
return createJournalEntry(supabase, companyId, userId, input)
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
-- Dimensions PR8 (salary): employees carry a default dimension bag so the
|
||||
-- salary booking can put each employee's cost on their kostnadsställe/projekt.
|
||||
--
|
||||
-- employees.default_dimensions {sie_dim_no: code}, e.g. {"1":"KS01"}
|
||||
--
|
||||
-- Read at book time by both salary book routes (dashboard + v1) and merged
|
||||
-- onto the P&L cost lines of the salary/avgifter/semester/pension entries;
|
||||
-- balance-sheet legs (2710, 1930, 2731, 29xx, 2740, 2514) stay aggregated.
|
||||
-- Same shape + CHECK as journal_entry_lines.dimensions (20260702084500) and
|
||||
-- the PR7 producer columns (20260702200000). NOT NULL DEFAULT '{}' is
|
||||
-- metadata-only on PG11+ (no rewrite). No index: read via the employee row
|
||||
-- when booking, never containment-queried.
|
||||
--
|
||||
-- pg-test: covered-by — plain column add with a type CHECK, no
|
||||
-- trigger/RPC/RLS/DEFERRABLE change. Propagation logic is TS-side
|
||||
-- (lib/salary/salary-entries.ts unit tests).
|
||||
|
||||
ALTER TABLE public.employees
|
||||
ADD COLUMN default_dimensions jsonb NOT NULL DEFAULT '{}'::jsonb;
|
||||
ALTER TABLE public.employees
|
||||
ADD CONSTRAINT employees_default_dimensions_is_object
|
||||
CHECK (jsonb_typeof(default_dimensions) = 'object');
|
||||
|
||||
COMMENT ON COLUMN public.employees.default_dimensions IS
|
||||
'Dimension bag {sie_dim_no: code} applied to the employee''s P&L cost lines when a salary run is booked. See lib/salary/salary-entries.ts.';
|
||||
|
||||
NOTIFY pgrst, 'reload schema';
|
||||
@@ -3302,6 +3302,10 @@ export interface Employee {
|
||||
vaxa_stod_eligible: boolean
|
||||
vaxa_stod_start: string | null
|
||||
vaxa_stod_end: string | null
|
||||
// Dimensions PR8: bag ({sie_dim_no: code}) applied to this employee's P&L
|
||||
// cost lines when a salary run is booked. jsonb DEFAULT '{}'. Optional in
|
||||
// TS for pre-migration fixtures.
|
||||
default_dimensions?: Record<string, string>
|
||||
is_active: boolean
|
||||
created_at: string
|
||||
updated_at: string
|
||||
|
||||
Reference in New Issue
Block a user