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:
co-authored by
Claude Fable 5
parent
4a9fa5e6c5
commit
4bb0655e4a
@@ -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
@@ -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([
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}`
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user