feat(salary): öresavrundning of net pay to whole kronor (#1609)

* feat(salary): öresavrundning of net pay to whole kronor

Some banks reject salary payment files whose amounts carry öre. New
company_settings.salary_net_rounding toggle (off by default): the engine
rounds each net payout up to the next whole krona, never down, and emits
a derived oresavrundning line item (semesterersattning pattern) that
debits 3740 Öres- och kronutjämning so the salary entry stays balanced.
Gross, tax and avgifter are untouched, so AGI/KU are unaffected. Payment
files (pain.001 + Bankgirot LB) get whole-krona amounts via the rounded
net_salary. Toggle in salary settings; payslip and run detail show the
line item.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(salary): keep employer cost on the shared definition; block manual rounding lines

Skeptic findings on the öresavrundning commit: (1) the engine included
netRounding in totalEmployerCost while payslip summary, KPI cards and
lönejournal recompute the figure from stored columns, printing two
different totals on the same payslip; employer cost now stays on the
shared definition and the öre cost is carried by the 3740 ledger line.
(2) 'oresavrundning' is excluded from the line-item create/update
schemas: it is the only item type the booking keeps out of the gross
reconciliation, so a manually created row would structurally unbalance
the salary verifikat.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(salary): add the item_type CHECK as NOT VALID, validate separately

Compliance-swarm finding (SOC 2 CC8.1): the CHECK re-add scanned
salary_line_items under the ADD's ACCESS EXCLUSIVE lock. Split per the
house pattern (DECISIONS.md 2026-07-13): 20260813143000 re-adds the
constraint NOT VALID, new 20260813143001 validates it under SHARE UPDATE
EXCLUSIVE in its own transaction. The list is a strict superset of the
previous CHECK, so validation cannot fail. Both files are branch-only,
so editing in place is within the never-modify-shipped rule.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Mattsson
2026-08-14 00:36:33 +02:00
committed by GitHub
co-authored by Claude Fable 5
parent 4a9fa5e6c5
commit 4bb0655e4a
18 changed files with 470 additions and 2 deletions
+19
View File
@@ -2815,3 +2815,22 @@ describe('CreateRecurringScheduleSchema interval_months', () => {
expect(CreateRecurringScheduleSchema.safeParse({ ...base, interval_months: 1.5 }).success).toBe(false)
})
})
describe('CreateSalaryLineItemSchema: derived-only item types', () => {
it("rejects manual 'oresavrundning' lines (only the calculator may write them)", async () => {
const { CreateSalaryLineItemSchema, UpdateSalaryLineItemSchema } = await import('../schemas')
const base = {
salary_run_employee_id: '3f0a2f60-0000-4000-8000-000000000001',
description: 'Öresavrundning',
amount: 0.4,
}
expect(
CreateSalaryLineItemSchema.safeParse({ ...base, item_type: 'oresavrundning' }).success,
).toBe(false)
expect(
CreateSalaryLineItemSchema.safeParse({ ...base, item_type: 'bonus' }).success,
).toBe(true)
expect(UpdateSalaryLineItemSchema.safeParse({ item_type: 'oresavrundning' }).success).toBe(false)
expect(UpdateSalaryLineItemSchema.safeParse({ item_type: 'bonus' }).success).toBe(true)
})
})
+11 -1
View File
@@ -2014,6 +2014,9 @@ export const UpdateSettingsSchema = z.object({
.enum(['swedbank', 'seb', 'handelsbanken', 'nordea', 'other'])
.nullable()
.optional(),
// Öresavrundning: round each net payout up to whole kronor (banks that
// reject öre in salary payment files). Diff books on 3740.
salary_net_rounding: z.boolean().optional(),
// Vacation year basis (payroll gap-closure 3.1): sammanfallande calendar
// year (default) or the statutory Apr 1 - Mar 31 split. The settings route
// blocks changing this while open vacation-ledger rows exist.
@@ -2514,6 +2517,7 @@ export const SalaryLineItemTypeSchema = z.enum([
'mileage_taxfree', 'mileage_taxable',
'net_deduction_advance', 'net_deduction_union', 'net_deduction_benefit_payment',
'net_deduction_other',
'oresavrundning',
'correction', 'other',
])
@@ -2893,7 +2897,13 @@ export const AddEmployeeToRunSchema = z.object({
export const CreateSalaryLineItemSchema = z.object({
salary_run_employee_id: uuid,
item_type: SalaryLineItemTypeSchema,
// 'oresavrundning' is derived-only: the calculator writes it from the
// engine's netRounding and the booking excludes it from the gross
// reconciliation, so a manually created row would unbalance the salary
// verifikat by exactly its amount (the DB balance trigger then rejects the
// booking). Every other derived type is absorbed by the base remainder and
// stays harmless to create by hand.
item_type: SalaryLineItemTypeSchema.exclude(['oresavrundning']),
description: z.string().min(1).max(500),
quantity: z.number().optional(),
unit_price: z.number().optional(),
@@ -1331,3 +1331,133 @@ describe('calculateSalary: shift premiums (OB-tillägg och övertid)', () => {
expect(additionStep?.output).toBe(2400)
})
})
describe('öresavrundning (roundNetToWholeKrona)', () => {
const r2 = (x: number) => Math.round(x * 100) / 100
const bonus = (amount: number) => ({
itemType: 'bonus' as const,
amount,
isTaxable: true,
isAvgiftBasis: true,
isVacationBasis: false,
isGrossDeduction: false,
isNetDeduction: false,
})
const netDeduction = (amount: number) => ({
itemType: 'net_deduction_other' as const,
amount,
isTaxable: false,
isAvgiftBasis: false,
isVacationBasis: false,
isGrossDeduction: false,
isNetDeduction: true,
})
it('is off by default: net keeps its öre and netRounding is 0', () => {
const result = calculateSalary(
makeBasicInput({ fSkattStatus: 'f_skatt', lineItems: [bonus(0.63)] }),
config2026,
emptyTaxRates,
)
expect(result.netSalary).toBe(40000.63)
expect(result.netRounding).toBe(0)
expect(result.steps.find((s) => s.label.includes('Öresavrundning'))).toBeUndefined()
})
it('rounds a .01 net up to the next whole krona (rounding 0.99)', () => {
const result = calculateSalary(
makeBasicInput({ fSkattStatus: 'f_skatt', lineItems: [bonus(0.01)], roundNetToWholeKrona: true }),
config2026,
emptyTaxRates,
)
expect(result.netSalary).toBe(40001)
expect(result.netRounding).toBe(0.99)
})
it('rounds a .99 net up by a single öre', () => {
const result = calculateSalary(
makeBasicInput({ fSkattStatus: 'f_skatt', lineItems: [bonus(0.99)], roundNetToWholeKrona: true }),
config2026,
emptyTaxRates,
)
expect(result.netSalary).toBe(40001)
expect(result.netRounding).toBe(0.01)
})
it('leaves a whole-krona net untouched (no rounding step)', () => {
const result = calculateSalary(
makeBasicInput({ roundNetToWholeKrona: true }),
config2026,
emptyTaxRates,
)
expect(result.netSalary).toBe(28000)
expect(result.netRounding).toBe(0)
expect(result.steps.find((s) => s.label.includes('Öresavrundning'))).toBeUndefined()
})
it('rounds only the net: gross, tax and avgifter stay exact', () => {
// Tax withholding is always whole kronor (SFF 22 kap. 1 §), so the öre
// comes from the gross: 40000.30 − 12000 = 28000.30 → 28001.
const result = calculateSalary(
makeBasicInput({ lineItems: [bonus(0.3)], roundNetToWholeKrona: true }),
config2026,
emptyTaxRates,
)
expect(result.grossSalary).toBe(40000.3)
expect(result.taxWithheld).toBe(12000)
expect(result.netSalary).toBe(28001)
expect(result.netRounding).toBe(0.7)
expect(result.avgifterAmount).toBe(r2(40000.3 * 0.3142))
const step = result.steps.find((s) => s.label.includes('Öresavrundning'))
expect(step).toBeDefined()
expect(step?.output).toBe(28001)
})
it('applies after nettolöneavdrag', () => {
const result = calculateSalary(
makeBasicInput({
fSkattStatus: 'f_skatt',
lineItems: [netDeduction(-100.75)],
roundNetToWholeKrona: true,
}),
config2026,
emptyTaxRates,
)
// 40000 - 100.75 = 39899.25 → up to 39900
expect(result.netSalary).toBe(39900)
expect(result.netRounding).toBe(0.75)
})
it('never rounds a zero payout', () => {
const result = calculateSalary(
makeBasicInput({ monthlySalary: 0, roundNetToWholeKrona: true }),
config2026,
emptyTaxRates,
)
expect(result.netSalary).toBe(0)
expect(result.netRounding).toBe(0)
})
it('keeps total employer cost on the shared definition (rounding excluded)', () => {
// Payslip summary, KPI cards and lönejournal all recompute employer cost
// as gross + avgifter + semester + avgifter-på-semester; the engine must
// match or the same payslip would print two different totals. The öre
// cost is carried by the 3740 ledger line instead.
const result = calculateSalary(
makeBasicInput({ lineItems: [bonus(0.3)], roundNetToWholeKrona: true }),
config2026,
emptyTaxRates,
)
expect(result.netRounding).toBeGreaterThan(0)
expect(result.totalEmployerCost).toBe(
r2(
result.grossSalary +
result.avgifterAmount +
result.vacationAccrual +
result.vacationAccrualAvgifter,
),
)
})
})
@@ -221,6 +221,91 @@ describe('salary entries: net deductions', () => {
})
})
describe('salary entries: öresavrundning', () => {
const roundingItem = (amount: number) => ({
item_type: 'oresavrundning',
amount,
account_number: '3740',
is_net_deduction: false,
is_gross_deduction: false,
})
it('debits 3740 for the rounding without shrinking the base salary line', async () => {
// net_salary is stored rounded (22999.70 → 23000); the line item carries
// the 0.30 diff. The 7210 debit must stay the full gross.
const run = makeRun([
makeEmployee({
gross_salary: 30000,
tax_withheld: 7000.3,
net_salary: 23000,
line_items: [roundingItem(0.3)],
}),
])
await createSalaryRunEntries(makeSupabase(), 'company-1', 'user-1', run)
const salary = entryByDescription('Lön 2026-06')
expect(linesOn(salary, '7210')[0].debit_amount).toBe(30000)
expect(linesOn(salary, '3740')[0].debit_amount).toBe(0.3)
expect(linesOn(salary, '2710')[0].credit_amount).toBe(7000.3)
expect(linesOn(salary, '1930')[0].credit_amount).toBe(23000)
assertBalanced(salary)
})
it('keeps the base remainder correct next to other line items', async () => {
const run = makeRun([
makeEmployee({
gross_salary: 32000,
tax_withheld: 8000.55,
net_salary: 24000,
line_items: [
{ item_type: 'overtime', amount: 2000, account_number: '7281', is_net_deduction: false, is_gross_deduction: false },
roundingItem(0.55),
],
}),
])
await createSalaryRunEntries(makeSupabase(), 'company-1', 'user-1', run)
const salary = entryByDescription('Lön 2026-06')
expect(linesOn(salary, '7281')[0].debit_amount).toBe(2000)
// Remainder is gross - overtime, NOT gross - overtime - rounding.
expect(linesOn(salary, '7210')[0].debit_amount).toBe(30000)
expect(linesOn(salary, '3740')[0].debit_amount).toBe(0.55)
assertBalanced(salary)
})
it('tags the rounding line with the employee dimensions bag and aggregates per bag', async () => {
const run = makeRun([
makeEmployee({
employee_id: 'a',
gross_salary: 30000,
tax_withheld: 7000.3,
net_salary: 23000,
default_dimensions: { '1': 'KS01' },
line_items: [roundingItem(0.3)],
}),
makeEmployee({
employee_id: 'b',
gross_salary: 30000,
tax_withheld: 7000.6,
net_salary: 23000,
default_dimensions: { '1': 'KS01' },
line_items: [roundingItem(0.6)],
}),
])
await createSalaryRunEntries(makeSupabase(), 'company-1', 'user-1', run)
const salary = entryByDescription('Lön 2026-06')
const roundingLines = linesOn(salary, '3740')
expect(roundingLines).toHaveLength(1)
expect(roundingLines[0].debit_amount).toBe(0.9)
expect(roundingLines[0].dimensions).toEqual({ '1': 'KS01' })
assertBalanced(salary)
})
})
describe('salary entries: dimensions propagation (PR8)', () => {
it('splits the salary expense per employee bag; tax and bank legs stay untagged', async () => {
const run = makeRun([
+3
View File
@@ -57,6 +57,9 @@ const LINE_ITEM_ACCOUNTS: Record<SalaryLineItemType, string> = {
net_deduction_union: '2794',
net_deduction_benefit_payment: '7385',
net_deduction_other: '2799',
// Öresavrundning: net payout rounded up to whole kronor; the 0-99 öre diff
// debits the standard rounding account (same account the invoice flows use).
oresavrundning: '3740',
// Other
correction: '7210',
other: '7210',
+39 -1
View File
@@ -50,6 +50,15 @@ export interface SalaryCalculationInput {
/** Line items */
lineItems: CalculationLineItem[]
/**
* Öresavrundning (company_settings.salary_net_rounding): round the net
* payout UP to the nearest whole krona. Never down: rounding down would
* underpay wages. The 0-99 öre difference is returned as netRounding and
* booked on 3740 Öres- och kronutjämning via a derived line item. Gross
* salary, tax and avgifter are unaffected.
*/
roundNetToWholeKrona?: boolean
/**
* Pay period bounds (YYYY-MM-DD). Together with employmentStart/employmentEnd
* they drive partial-month proration: an employee hired mid-period or
@@ -88,6 +97,8 @@ export interface SalaryCalculationResult {
taxWithheld: number
netDeductions: number
netSalary: number
/** Öre added to reach a whole-krona net payout (0 when rounding is off or the net is already whole). */
netRounding: number
avgifterRate: number
avgifterAmount: number
avgifterBasis: number
@@ -465,7 +476,7 @@ export function calculateSalary(
const netDeductionItems = input.lineItems.filter(li => li.isNetDeduction)
const totalNetDeductions = r(Math.abs(netDeductionItems.reduce((sum, li) => sum + li.amount, 0)))
const netSalary = r(grossSalary - taxWithheld - totalNetDeductions)
let netSalary = r(grossSalary - taxWithheld - totalNetDeductions)
steps.push({
label: 'Nettolön',
formula: 'bruttolön − skatt − nettoavdrag',
@@ -473,6 +484,26 @@ export function calculateSalary(
output: netSalary,
})
// ─── Step 7b: Öresavrundning (optional, uppåt till hel krona) ───
// Integer öre arithmetic: netSalary is already r()-rounded so netOre is
// exact; ceil-by-remainder avoids float noise. Only positive payouts round:
// a zero or negative net produces no payment-file line to round.
let netRounding = 0
if (input.roundNetToWholeKrona && netSalary > 0) {
const netOre = Math.round(netSalary * 100)
const remainderOre = netOre % 100
if (remainderOre !== 0) {
netRounding = (100 - remainderOre) / 100
netSalary = (netOre + 100 - remainderOre) / 100
steps.push({
label: 'Öresavrundning (uppåt till hel krona)',
formula: 'nettolön avrundas uppåt till hel krona',
input: { net_before_rounding: r(netOre / 100), rounding: netRounding },
output: netSalary,
})
}
}
// ─── Step 8: Employer contributions (avgifter) ───
const avgifterCalc = calculateAvgifterRate(input, config, paymentYear)
const avgifterBasis = input.fSkattStatus === 'f_skatt' ? 0 : r(grossSalary + totalBenefits)
@@ -590,6 +621,12 @@ export function calculateSalary(
output: vacationAccrualAvgifter,
})
// totalEmployerCost deliberately EXCLUDES netRounding: payslip summary,
// run KPI cards and the lönejournal all recompute this figure as
// gross + avgifter + semester + avgifter-på-semester from stored columns,
// so including the rounding only here would print two different totals on
// the same payslip. The öre cost is still real and lives in the ledger as
// the 3740 debit.
const totalEmployerCost = r(grossSalary + avgifterAmount + vacationAccrual + vacationAccrualAvgifter)
steps.push({
label: 'Total arbetsgivarkostnad',
@@ -606,6 +643,7 @@ export function calculateSalary(
taxWithheld,
netDeductions: totalNetDeductions,
netSalary,
netRounding,
avgifterRate: avgifterCalc.rate,
avgifterAmount,
avgifterBasis,
+49
View File
@@ -146,6 +146,19 @@ export async function runSalaryCalculation(
// 2. Load year config.
const config = await loadPayrollConfig(supabase, paymentYear)
// 2b. Company-level öresavrundning toggle: round each net payout up to a
// whole krona (banks that reject öre in salary files). maybeSingle: a
// company without a settings row keeps the default (off).
const { data: companySettings, error: settingsError } = await supabase
.from('company_settings')
.select('salary_net_rounding')
.eq('company_id', companyId)
.maybeSingle()
if (settingsError) {
return { ok: false, code: 'DATABASE_ERROR', details: settingsError }
}
const roundNetToWholeKrona = companySettings?.salary_net_rounding === true
// 3. Load roster: `salary_run_employees` joined with employees + line items.
// Defense-in-depth: filter by company_id too even though salary_run_id is a
// foreign key. RLS already constrains the table per-company, but per
@@ -642,6 +655,7 @@ export async function runSalaryCalculation(
if (DERIVED_PREMIUM_TYPES.includes(li.item_type as ShiftPremiumItemType)) return false
if (li.source_benefit_id) return false
if (li.item_type === 'semesterersattning') return false
if (li.item_type === 'oresavrundning') return false
return true
})
.map((li: Record<string, unknown>) => ({
@@ -715,6 +729,7 @@ export async function runSalaryCalculation(
periodEnd,
employmentStart: emp.employment_start,
employmentEnd: emp.employment_end,
roundNetToWholeKrona,
},
config,
taxRates.map((r) => ({ ...r })),
@@ -813,6 +828,40 @@ export async function runSalaryCalculation(
}
}
// 8i. Replace the derived 'oresavrundning' line item. All flags false: the
// rounding is not pay, not tax base, not avgift basis; it exists so
// the payslip shows the whole-krona step and the booking gets its 3740
// debit. Deleted unconditionally so toggling the setting off (or a net
// that lands on a whole krona) leaves no stale row behind.
const { error: delRoundErr } = await supabase
.from('salary_line_items')
.delete()
.eq('salary_run_employee_id', sre.id)
.eq('item_type', 'oresavrundning')
if (delRoundErr) {
return { ok: false, code: 'DATABASE_ERROR', details: delRoundErr }
}
if (result.netRounding > 0) {
const { error: insRoundErr } = await supabase.from('salary_line_items').insert({
salary_run_employee_id: sre.id,
company_id: companyId,
item_type: 'oresavrundning',
description: 'Öresavrundning',
quantity: 1,
amount: Math.round(result.netRounding * 100) / 100,
is_taxable: false,
is_avgift_basis: false,
is_vacation_basis: false,
is_gross_deduction: false,
is_net_deduction: false,
account_number: getLineItemAccount('oresavrundning', emp.employment_type),
sort_order: 900,
})
if (insRoundErr) {
return { ok: false, code: 'DATABASE_ERROR', details: insRoundErr }
}
}
totalGross += result.grossSalary
totalTax += result.taxWithheld
totalNet += result.netSalary
+11
View File
@@ -223,6 +223,16 @@ async function createSalaryEntry(
const BENEFIT_TYPES = ['benefit_car', 'benefit_housing', 'benefit_meals', 'benefit_wellness', 'benefit_bike', 'benefit_other']
let lineItemTotal = 0
for (const li of emp.line_items) {
// Öresavrundning: part of the payout (the 1930 credit uses the rounded
// net) but NOT part of gross salary, so it must stay out of
// lineItemTotal: the baseRemainder below reconciles line items against
// gross_salary, and counting the rounding there would shrink the base
// salary debit by the same amount and unbalance the entry.
if (li.item_type === 'oresavrundning') {
const account = li.account_number || getLineItemAccount('oresavrundning', emp.employment_type)
addExpense(account, dimensions, li.amount)
continue
}
if (li.is_net_deduction) {
const account = li.account_number || getLineItemAccount(li.item_type as never, emp.employment_type)
netDeductionBuckets.set(account, (netDeductionBuckets.get(account) ?? 0) + li.amount)
@@ -666,6 +676,7 @@ function accountLabel(account: string): string {
'1613': 'Övriga förskott',
'2794': 'Fackföreningsavgifter',
'2799': 'Övriga löneavdrag',
'3740': 'Öres- och kronutjämning',
}
return labels[account] || `Konto ${account}`
}