Bug/employees creation (#440)
* Fix/employees page salary display logic and labels * feat: add salary_worked_days table and related functionality - Implemented the salary_worked_days table to track per-day worked hours for hourly employees. - Established row-level security (RLS) policies to ensure tenant isolation for salary_worked_days. - Created unique index on (employee_id, work_date) to enforce uniqueness. - Added trigger to enforce a 24-hour cap across worked and absence days for the same employee and date. - Developed tests to validate RLS, uniqueness, and 24-hour cap logic. * Fix: update hourly_salary calculation and refresh logic in salary run processing
This commit is contained in:
@@ -852,6 +852,37 @@ export const AbsenceRangeQuerySchema = z.object({
|
||||
path: ['from'],
|
||||
})
|
||||
|
||||
// ── Worked-hours per-day records (hourly employees) ─────────────────
|
||||
//
|
||||
// Drives base salary calculation for hourly (timanställd) employees:
|
||||
// `baseSalary = hourly_rate × Σ hours`. Mirrors absence days deliberately —
|
||||
// same calendar UX, half-day mixing with absence enforced by the 24h cap
|
||||
// trigger. The calculator sums these per pay period at calculate time.
|
||||
|
||||
export const UpsertWorkedDaySchema = z.object({
|
||||
work_date: isoDate,
|
||||
hours: z.number().positive().max(24).default(8),
|
||||
notes: z.string().max(2000).optional(),
|
||||
salary_run_employee_id: uuid.optional(),
|
||||
})
|
||||
|
||||
export const WorkedHoursRangeQuerySchema = z.object({
|
||||
from: isoDate,
|
||||
to: isoDate,
|
||||
}).refine((data) => data.from <= data.to, {
|
||||
message: '`from` måste vara före eller lika med `to`',
|
||||
path: ['from'],
|
||||
})
|
||||
|
||||
export const BatchUpsertWorkedDaysSchema = z.object({
|
||||
// 100-row sanity cap: typical use is one pay period (~22 weekdays). A larger
|
||||
// value usually indicates the caller is iterating wrong.
|
||||
dates: z.array(isoDate).min(1).max(100),
|
||||
hours: z.number().positive().max(24).default(8),
|
||||
notes: z.string().max(2000).optional(),
|
||||
salary_run_employee_id: uuid.optional(),
|
||||
})
|
||||
|
||||
// ============================================================
|
||||
// AI agent flow schemas
|
||||
// ============================================================
|
||||
|
||||
@@ -116,26 +116,35 @@ const SWEDISH_STEMS = new Set<string>([
|
||||
'försäljnings', 'förskola', 'församling', 'förvaltning', 'förbund',
|
||||
'förlag', 'föräldra', 'försök', 'förbrukning', 'förbättring', 'förskott',
|
||||
'försening', 'förhandling', 'förbättrings',
|
||||
// domain terms
|
||||
// för-* derivatives that often appear in account names
|
||||
'förmån', 'förmåner', 'förmedlad', 'förmedlade', 'förmedling', 'förmedlings',
|
||||
'förvaltar', 'förmedla',
|
||||
// -mark- / marknadsföring
|
||||
'marknadsföring', 'marknadsförings', 'marknad',
|
||||
// common Swedish words/prefixes
|
||||
'bostadsrätt', 'rätt', 'samfällighet', 'idrott', 'fastighet', 'utbildning',
|
||||
'näring', 'växel', 'värme', 'köp', 'köpa', 'inköp', 'sälja', 'säljs',
|
||||
// accounting
|
||||
'tjänst', 'tjänster',
|
||||
// accounting terms
|
||||
'kostnad', 'kostnader', 'intäkt', 'intäkter', 'avskrivning', 'avsättning',
|
||||
'lön', 'lönekostnad', 'pension', 'utgående', 'ingående', 'momspliktig',
|
||||
'redovisning', 'företagskonto', 'bankkonto', 'överavskrivning',
|
||||
'överskott', 'underskott', 'överföring', 'överlåtelse', 'återbetalning',
|
||||
'utlägg', 'utgift',
|
||||
// common short prepositions and adverbs
|
||||
'från', 'för', 'över', 'är', 'när', 'där', 'även', 'någon', 'något',
|
||||
'många', 'själv', 'små', 'väg', 'gång', 'tjänst', 'tjänster', 'räkning',
|
||||
'räntor', 'år',
|
||||
// common cities
|
||||
'lön', 'löner', 'lönekostnad', 'pension', 'utgående', 'ingående',
|
||||
'momspliktig', 'redovisning', 'företagskonto', 'bankkonto',
|
||||
'överavskrivning', 'överskott', 'underskott', 'överföring', 'överlåtelse',
|
||||
'återbetalning', 'utlägg', 'utgift', 'avdrag',
|
||||
// omvänd moms etc.
|
||||
'omvänd', 'omvänt', 'omvända',
|
||||
// 3+ letter prepositions/adverbs (skip 2-letter ones — too ambiguous)
|
||||
'från', 'över', 'när', 'där', 'även', 'någon', 'något', 'många',
|
||||
'själv', 'små', 'väg', 'gång', 'räkning', 'räntor', 'är',
|
||||
// 'på' — short but extremely common; include explicitly
|
||||
'på',
|
||||
// cities
|
||||
'göteborg', 'malmö', 'örebro', 'östersund', 'jönköping', 'linköping',
|
||||
'norrköping', 'lidköping', 'köping', 'helsingborg', 'umeå', 'skellefteå',
|
||||
'piteå', 'luleå', 'borås', 'växjö', 'östhammar', 'södertälje', 'västerås',
|
||||
'härnösand', 'värnamo', 'mölndal', 'mörrum', 'mönsterås', 'färjestaden',
|
||||
'eskilstuna',
|
||||
// directions / common geo terms
|
||||
// directions / geo
|
||||
'östra', 'västra', 'södra', 'norra', 'öster', 'väster', 'söder',
|
||||
// legal forms
|
||||
'aktiebolag', 'handelsbolag', 'ekonomisk', 'allmännyttig',
|
||||
@@ -148,19 +157,21 @@ const SWEDISH_STEMS = new Set<string>([
|
||||
])
|
||||
|
||||
/**
|
||||
* Score a candidate word.
|
||||
* - 1000 if the entire word matches a known stem (highest confidence).
|
||||
* - Otherwise the count of distinct stems that appear as substrings.
|
||||
* Counting (not boolean-returning) is required: when the same word has
|
||||
* multiple U+FFFD positions, the correct combination must outscore wrong
|
||||
* combinations that still happen to contain *one* stem each.
|
||||
* Score a candidate word by *length-weighted* stem matching.
|
||||
*
|
||||
* - Exact word match: 1_000_000 (still beats any substring sum).
|
||||
* - Otherwise: sum the lengths of every stem that is a substring of the
|
||||
* candidate. Length-weighting is the critical fix vs. simple counting:
|
||||
* "fårmedlad" hits "år" (2 chars), "förmedlad" hits "för" (3 chars).
|
||||
* Counting would tie them at 1; length-weighting gives 2 vs 3, so the
|
||||
* correct candidate wins.
|
||||
*/
|
||||
function scoreCandidate(word: string): number {
|
||||
const lower = word.toLowerCase()
|
||||
if (SWEDISH_STEMS.has(lower)) return 1000
|
||||
if (SWEDISH_STEMS.has(lower)) return 1_000_000
|
||||
let score = 0
|
||||
for (const stem of SWEDISH_STEMS) {
|
||||
if (lower.includes(stem)) score++
|
||||
if (lower.includes(stem)) score += stem.length
|
||||
}
|
||||
return score
|
||||
}
|
||||
|
||||
@@ -0,0 +1,163 @@
|
||||
import { randomUUID } from 'crypto'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { seedCompany } from '@/tests/pg/fixtures'
|
||||
import { getPool, withUserContext } from '@/tests/pg/setup'
|
||||
|
||||
/**
|
||||
* RLS + 24h cap smoke for salary_worked_days. Pay-period summing has unit
|
||||
* coverage in the calculate-route test; this file locks in the database-
|
||||
* level invariants that mocked Supabase clients can't exercise:
|
||||
* - Tenant isolation via RLS
|
||||
* - Unique (employee_id, work_date)
|
||||
* - 24h cap across worked + absence on the same date (allows half-day
|
||||
* mixing up to 24h, blocks overflow)
|
||||
*/
|
||||
|
||||
async function insertEmployee(params: {
|
||||
userId: string
|
||||
companyId: string
|
||||
}): Promise<string> {
|
||||
const id = randomUUID()
|
||||
const pnr = '199001011234'
|
||||
await getPool().query(
|
||||
`INSERT INTO public.employees
|
||||
(id, user_id, company_id, first_name, last_name, personnummer,
|
||||
personnummer_last4, employment_start, hourly_rate, salary_type, tax_table_number)
|
||||
VALUES ($1, $2, $3, 'Test', 'Person', $4, '1234', '2026-01-01', 250, 'hourly', 32)`,
|
||||
[id, params.userId, params.companyId, pnr],
|
||||
)
|
||||
return id
|
||||
}
|
||||
|
||||
async function insertWorkedDay(params: {
|
||||
companyId: string
|
||||
employeeId: string
|
||||
date: string
|
||||
hours?: number
|
||||
}): Promise<string> {
|
||||
const id = randomUUID()
|
||||
await getPool().query(
|
||||
`INSERT INTO public.salary_worked_days
|
||||
(id, company_id, employee_id, work_date, hours)
|
||||
VALUES ($1, $2, $3, $4, $5)`,
|
||||
[id, params.companyId, params.employeeId, params.date, params.hours ?? 8],
|
||||
)
|
||||
return id
|
||||
}
|
||||
|
||||
async function insertAbsenceDay(params: {
|
||||
companyId: string
|
||||
employeeId: string
|
||||
date: string
|
||||
hours?: number
|
||||
type?: string
|
||||
}): Promise<string> {
|
||||
const id = randomUUID()
|
||||
await getPool().query(
|
||||
`INSERT INTO public.salary_absence_days
|
||||
(id, company_id, employee_id, absence_date, absence_type, hours)
|
||||
VALUES ($1, $2, $3, $4, $5, $6)`,
|
||||
[id, params.companyId, params.employeeId, params.date, params.type ?? 'sick', params.hours ?? 8],
|
||||
)
|
||||
return id
|
||||
}
|
||||
|
||||
describe('salary_worked_days.pg — RLS tenant isolation', () => {
|
||||
it('a user only sees worked days for their own company', async () => {
|
||||
const a = await seedCompany()
|
||||
const b = await seedCompany()
|
||||
const empA = await insertEmployee({ userId: a.userId, companyId: a.companyId })
|
||||
const empB = await insertEmployee({ userId: b.userId, companyId: b.companyId })
|
||||
await insertWorkedDay({ companyId: a.companyId, employeeId: empA, date: '2026-04-15' })
|
||||
await insertWorkedDay({ companyId: b.companyId, employeeId: empB, date: '2026-04-16' })
|
||||
|
||||
const rowsA = await withUserContext(a.userId, async (client) => {
|
||||
const res = await client.query<{ company_id: string }>(
|
||||
`SELECT company_id FROM public.salary_worked_days`,
|
||||
)
|
||||
return res.rows
|
||||
})
|
||||
expect(rowsA).toHaveLength(1)
|
||||
expect(rowsA[0]!.company_id).toBe(a.companyId)
|
||||
})
|
||||
|
||||
it('blocks INSERT into another tenant via WITH CHECK', async () => {
|
||||
const a = await seedCompany()
|
||||
const b = await seedCompany()
|
||||
const empB = await insertEmployee({ userId: b.userId, companyId: b.companyId })
|
||||
|
||||
await expect(
|
||||
withUserContext(a.userId, async (client) => {
|
||||
return client.query(
|
||||
`INSERT INTO public.salary_worked_days
|
||||
(company_id, employee_id, work_date, hours)
|
||||
VALUES ($1, $2, '2026-04-17', 8)`,
|
||||
[b.companyId, empB],
|
||||
)
|
||||
}),
|
||||
).rejects.toThrow(/row-level security/i)
|
||||
})
|
||||
|
||||
it('enforces unique (employee_id, work_date)', async () => {
|
||||
const a = await seedCompany()
|
||||
const empA = await insertEmployee({ userId: a.userId, companyId: a.companyId })
|
||||
await insertWorkedDay({ companyId: a.companyId, employeeId: empA, date: '2026-04-15' })
|
||||
await expect(
|
||||
insertWorkedDay({ companyId: a.companyId, employeeId: empA, date: '2026-04-15' }),
|
||||
).rejects.toThrow(/duplicate key|unique/i)
|
||||
})
|
||||
})
|
||||
|
||||
describe('salary_worked_days.pg — 24h cap across worked + absence', () => {
|
||||
it('allows half-day mixing (4h worked + 4h sick = 8h total)', async () => {
|
||||
const a = await seedCompany()
|
||||
const empA = await insertEmployee({ userId: a.userId, companyId: a.companyId })
|
||||
await insertWorkedDay({ companyId: a.companyId, employeeId: empA, date: '2026-04-20', hours: 4 })
|
||||
// Should not throw — combined 4h + 4h = 8h ≤ 24h.
|
||||
await expect(
|
||||
insertAbsenceDay({ companyId: a.companyId, employeeId: empA, date: '2026-04-20', hours: 4 }),
|
||||
).resolves.not.toThrow()
|
||||
})
|
||||
|
||||
it('allows up to exactly 24h combined', async () => {
|
||||
const a = await seedCompany()
|
||||
const empA = await insertEmployee({ userId: a.userId, companyId: a.companyId })
|
||||
await insertWorkedDay({ companyId: a.companyId, employeeId: empA, date: '2026-04-21', hours: 16 })
|
||||
await expect(
|
||||
insertAbsenceDay({ companyId: a.companyId, employeeId: empA, date: '2026-04-21', hours: 8 }),
|
||||
).resolves.not.toThrow()
|
||||
})
|
||||
|
||||
it('blocks worked day that pushes total above 24h', async () => {
|
||||
const a = await seedCompany()
|
||||
const empA = await insertEmployee({ userId: a.userId, companyId: a.companyId })
|
||||
await insertAbsenceDay({ companyId: a.companyId, employeeId: empA, date: '2026-04-22', hours: 20 })
|
||||
await expect(
|
||||
insertWorkedDay({ companyId: a.companyId, employeeId: empA, date: '2026-04-22', hours: 6 }),
|
||||
).rejects.toThrow(/Total tid.*24 timmar/i)
|
||||
})
|
||||
|
||||
it('blocks absence day that pushes total above 24h', async () => {
|
||||
const a = await seedCompany()
|
||||
const empA = await insertEmployee({ userId: a.userId, companyId: a.companyId })
|
||||
await insertWorkedDay({ companyId: a.companyId, employeeId: empA, date: '2026-04-23', hours: 20 })
|
||||
await expect(
|
||||
insertAbsenceDay({ companyId: a.companyId, employeeId: empA, date: '2026-04-23', hours: 6 }),
|
||||
).rejects.toThrow(/Total tid.*24 timmar/i)
|
||||
})
|
||||
|
||||
it('UPDATE on existing row excludes own previous contribution', async () => {
|
||||
// Bug guard: if the trigger summed including the row being updated,
|
||||
// editing 8h → 6h on a day with 8h absence would falsely report 16h+8h.
|
||||
const a = await seedCompany()
|
||||
const empA = await insertEmployee({ userId: a.userId, companyId: a.companyId })
|
||||
const wid = await insertWorkedDay({ companyId: a.companyId, employeeId: empA, date: '2026-04-24', hours: 8 })
|
||||
await insertAbsenceDay({ companyId: a.companyId, employeeId: empA, date: '2026-04-24', hours: 8 })
|
||||
await expect(
|
||||
getPool().query(
|
||||
`UPDATE public.salary_worked_days SET hours = 6 WHERE id = $1`,
|
||||
[wid],
|
||||
),
|
||||
).resolves.not.toThrow()
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user