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
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user