From 4bb0655e4a0a90e8ab4682130ccc465f2734cb13 Mon Sep 17 00:00:00 2001 From: Mattsson <111893710+mattssonn@users.noreply.github.com> Date: Fri, 14 Aug 2026 00:36:33 +0200 Subject: [PATCH] =?UTF-8?q?feat(salary):=20=C3=B6resavrundning=20of=20net?= =?UTF-8?q?=20pay=20to=20whole=20kronor=20(#1609)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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 * 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 * 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 --------- Co-authored-by: Claude Fable 5 --- DECISIONS.md | 2 + .../runs/[id]/employees/[employeeId]/page.tsx | 1 + app/api/settings/__tests__/route.test.ts | 31 +++++ .../sections/SalarySettingsContent.tsx | 16 +++ lib/api/__tests__/schemas.test.ts | 19 +++ lib/api/schemas.ts | 12 +- .../__tests__/calculation-engine.test.ts | 130 ++++++++++++++++++ lib/salary/__tests__/salary-entries.test.ts | 85 ++++++++++++ lib/salary/account-mapping.ts | 3 + lib/salary/calculation-engine.ts | 40 +++++- lib/salary/run-calculation.ts | 49 +++++++ lib/salary/salary-entries.ts | 11 ++ messages/en.json | 4 + messages/sv.json | 4 + .../20260813143000_salary_net_rounding.sql | 55 ++++++++ ...1_validate_salary_line_items_item_type.sql | 5 + tests/helpers.ts | 1 + types/index.ts | 4 + 18 files changed, 470 insertions(+), 2 deletions(-) create mode 100644 supabase/migrations/20260813143000_salary_net_rounding.sql create mode 100644 supabase/migrations/20260813143001_validate_salary_line_items_item_type.sql diff --git a/DECISIONS.md b/DECISIONS.md index 73791e2b..b7df2895 100644 --- a/DECISIONS.md +++ b/DECISIONS.md @@ -956,6 +956,8 @@ One line per decision: `[YYYY-MM-DD] : `. Appended by agents and [2026-08-13] PR #1598, compliance findings closed with the rollout after the Swedish accounting review escalated them from follow-up to fix-with-rollout: (a) runReconciliation's >= 0.9 auto-apply now writes 'matched' to payment_match_log (behandlingshistorik, BFNAR 2013:2 kap 8); (b) the three match-route storno-conflict branches no longer storno-reverse a reconciliation-linked verifikat: a reconciliation link points at an independent verifikat that may evidence other affarshandelser, and a wholesale reversal is an over-broad rattelse (BFL 5 kap 5 §). The detach is DEFERRED (round 2, CodeRabbit): nothing is persisted up front; the final transaction update overwrites the pointer and clears reconciliation_method in the same write, so a failure anywhere in the match flow leaves the existing link intact, and the release is logged as 'unmatched' after the commit. [2026-08-13] PR #1598, CodeRabbit findings: confirm-suggestions maxDuration 300; lookbackTouched on the migrator nudge buttons; persistSuggestions on main's post-backfill sweep; sie_sweep stamp errors logged; sandbox keeps the CSV CTA (file import works there); payment_match_log CHECK swap now NOT VALID + VALIDATE (no table-scan under ACCESS EXCLUSIVE); every logMatchEvent call awaited (serverless can freeze unawaited work). [2026-08-13] Historical audit gap quantified on prod (read-only): 762 manual-method links across 52 companies since 2026-03-23 have no payment_match_log row (upper bound: includes linked_to_existing_voucher drops AND older unlogged manual paths). Not backfillable (the inserts never landed); the links themselves are intact on transactions. Recorded here as the explicit ops note the compliance review asked for. +[2026-08-13] Öresavrundning av nettolön: round-UP-only to whole kronor, booked on 3740 as a derived 'oresavrundning' line item (semesterersattning pattern), no per-employee carry-forward: rounding down would underpay wages, 3740 is the codebase's established rounding account, and a carry-forward ledger is not worth the complexity for max 99 öre/employee/month. +[2026-08-13] totalEmployerCost excludes the öresavrundning amount (skeptic finding): payslip summary, KPI cards and lönejournal recompute employer cost from stored columns, so an engine-only inclusion printed two different totals on the same payslip; the öre cost lives in the ledger as the 3740 debit instead. Manual 'oresavrundning' line items are schema-blocked: they are the only type the booking excludes from the gross reconciliation, so a hand-created one structurally unbalances the verifikat. [2026-08-13] Startkort empty states use inline gradient scrims over their strata images: this is imagery treatment inside a hero surface, not card chrome, so the "no bg-gradient on cards" rule deliberately does not apply there (and nowhere else). [2026-08-13] Startkort webp assets (public/startkort/) are rendered outputs from the strata-engine in the CRM workspace, with per-file sources and flags recorded in components/dashboard/startkort-assets.ts; regenerate there, never edit the webp files by hand. diff --git a/app/(dashboard)/salary/runs/[id]/employees/[employeeId]/page.tsx b/app/(dashboard)/salary/runs/[id]/employees/[employeeId]/page.tsx index 0efd4e62..63596a8c 100644 --- a/app/(dashboard)/salary/runs/[id]/employees/[employeeId]/page.tsx +++ b/app/(dashboard)/salary/runs/[id]/employees/[employeeId]/page.tsx @@ -50,6 +50,7 @@ const LINE_ITEM_TYPE_KEYS: Record = { net_deduction_union: 'li_net_deduction_union', net_deduction_benefit_payment: 'li_net_deduction_benefit_payment', net_deduction_other: 'li_net_deduction_other', + oresavrundning: 'li_oresavrundning', correction: 'li_correction', other: 'li_other', } diff --git a/app/api/settings/__tests__/route.test.ts b/app/api/settings/__tests__/route.test.ts index e4fd00ef..93b4cb29 100644 --- a/app/api/settings/__tests__/route.test.ts +++ b/app/api/settings/__tests__/route.test.ts @@ -652,4 +652,35 @@ describe('PUT /api/settings', () => { 'employee_vacation_balances', ]) }) + + it('accepts the öresavrundning toggle', async () => { + enqueueMany([ + { data: { onboarding_complete: true } }, // oldSettings + { data: { company_id: 'company-1', salary_net_rounding: true } }, // update result + ]) + + const request = createMockRequest('/api/settings', { + method: 'PUT', + body: { salary_net_rounding: true }, + }) + const response = await PUT(request, { params: Promise.resolve({}) }) + const { status, body } = await parseJsonResponse<{ data: { salary_net_rounding: boolean } }>(response) + + expect(status).toBe(200) + expect(body.data.salary_net_rounding).toBe(true) + }) + + it('rejects a non-boolean öresavrundning value', async () => { + enqueueMany([ + { data: { onboarding_complete: true } }, // oldSettings + ]) + + const request = createMockRequest('/api/settings', { + method: 'PUT', + body: { salary_net_rounding: 'yes' }, + }) + const response = await PUT(request, { params: Promise.resolve({}) }) + + expect(response.status).toBe(400) + }) }) diff --git a/components/settings/sections/SalarySettingsContent.tsx b/components/settings/sections/SalarySettingsContent.tsx index 77f552b4..fb7a0b51 100644 --- a/components/settings/sections/SalarySettingsContent.tsx +++ b/components/settings/sections/SalarySettingsContent.tsx @@ -14,6 +14,7 @@ import { SettingsSelect, } from '@/components/settings/SettingsRows' import { TaxTableStatus } from '@/components/salary/TaxTableStatus' +import { Switch } from '@/components/ui/switch' import { useSettings } from '@/components/settings/useSettings' import { resolveDefaultSeriesForSource } from '@/lib/bookkeeping/voucher-series-resolver' import type { CompanySettings } from '@/types' @@ -36,11 +37,15 @@ export function SalarySettingsContent() { const { settings, isLoading, updateSettings, refetch } = useSettings() // Controlled so the LB sunset note reacts to the selection before save. const [format, setFormat] = useState<'bg_lb' | 'pain001' | null>(null) + // Controlled: the Radix Switch is not a form element, so its value rides + // along in handleSave instead of FormData. + const [netRounding, setNetRounding] = useState(null) if (isLoading) return if (!settings) return const effectiveFormat = format ?? settings.preferred_payment_format ?? 'pain001' + const effectiveNetRounding = netRounding ?? settings.salary_net_rounding ?? false const currentSeries = resolveDefaultSeriesForSource(settings, 'salary_payment') function handleSave(formData: FormData) { @@ -54,6 +59,7 @@ export function SalarySettingsContent() { salary_pay_day: payDay, preferred_payment_format: paymentFormat, salary_default_bank: bank === 'none' ? null : bank, + salary_net_rounding: effectiveNetRounding, } // The booking engine resolves the series from the per-source-type map; @@ -132,6 +138,16 @@ export function SalarySettingsContent() { + + setNetRounding(next)} + /> + + diff --git a/lib/api/__tests__/schemas.test.ts b/lib/api/__tests__/schemas.test.ts index e18de624..92061699 100644 --- a/lib/api/__tests__/schemas.test.ts +++ b/lib/api/__tests__/schemas.test.ts @@ -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) + }) +}) diff --git a/lib/api/schemas.ts b/lib/api/schemas.ts index 093aef43..2a9fe101 100644 --- a/lib/api/schemas.ts +++ b/lib/api/schemas.ts @@ -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(), diff --git a/lib/salary/__tests__/calculation-engine.test.ts b/lib/salary/__tests__/calculation-engine.test.ts index 0c5ba6f9..6e16ad8d 100644 --- a/lib/salary/__tests__/calculation-engine.test.ts +++ b/lib/salary/__tests__/calculation-engine.test.ts @@ -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, + ), + ) + }) +}) diff --git a/lib/salary/__tests__/salary-entries.test.ts b/lib/salary/__tests__/salary-entries.test.ts index 25c36ed9..0b3cc705 100644 --- a/lib/salary/__tests__/salary-entries.test.ts +++ b/lib/salary/__tests__/salary-entries.test.ts @@ -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([ diff --git a/lib/salary/account-mapping.ts b/lib/salary/account-mapping.ts index 1a58614b..8a27091c 100644 --- a/lib/salary/account-mapping.ts +++ b/lib/salary/account-mapping.ts @@ -57,6 +57,9 @@ const LINE_ITEM_ACCOUNTS: Record = { 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', diff --git a/lib/salary/calculation-engine.ts b/lib/salary/calculation-engine.ts index f86859d9..0b6f50d4 100644 --- a/lib/salary/calculation-engine.ts +++ b/lib/salary/calculation-engine.ts @@ -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, diff --git a/lib/salary/run-calculation.ts b/lib/salary/run-calculation.ts index 6d609dc4..7082a5e8 100644 --- a/lib/salary/run-calculation.ts +++ b/lib/salary/run-calculation.ts @@ -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) => ({ @@ -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 diff --git a/lib/salary/salary-entries.ts b/lib/salary/salary-entries.ts index adf95a94..b3f62164 100644 --- a/lib/salary/salary-entries.ts +++ b/lib/salary/salary-entries.ts @@ -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}` } diff --git a/messages/en.json b/messages/en.json index ccb7bba3..2d3d0f18 100644 --- a/messages/en.json +++ b/messages/en.json @@ -1875,6 +1875,9 @@ "bank_none": "Not selected", "bank_other": "Other bank", "bank_help": "When selected, upload instructions for your bank are shown first on every payroll run.", + "net_rounding_label": "Öre rounding", + "net_rounding_toggle": "Round net pay up to whole kronor", + "net_rounding_help": "Net pay is rounded up to the nearest whole krona and the öre difference is booked on account 3740. Enable this if your bank rejects payment files with öre amounts.", "vacation_info": "The vacation rule (procentregeln 12% by default) and number of vacation days are set per employee.", "vacation_info_link": "Manage employees", "accounting_heading": "Bookkeeping", @@ -6854,6 +6857,7 @@ "li_net_deduction_union": "Net deduction — union fee", "li_net_deduction_benefit_payment": "Net deduction — benefit payment", "li_net_deduction_other": "Net deduction — other", + "li_oresavrundning": "Öre rounding", "li_correction": "Correction", "li_other": "Other", "error_load_run": "Could not load salary run", diff --git a/messages/sv.json b/messages/sv.json index e9a41de7..554d9291 100644 --- a/messages/sv.json +++ b/messages/sv.json @@ -1875,6 +1875,9 @@ "bank_none": "Inte valt", "bank_other": "Annan bank", "bank_help": "Förvalt visar vi uppladdningsinstruktioner för din bank vid varje lönekörning.", + "net_rounding_label": "Öresavrundning", + "net_rounding_toggle": "Avrunda nettolön uppåt till hel krona", + "net_rounding_help": "Nettolönen avrundas uppåt till hel krona och öresdifferensen bokförs på konto 3740. Välj detta om din bank inte tar emot betalfiler med ören.", "vacation_info": "Semesterregeln (procentregeln 12 % som standard) och antal semesterdagar ställs in per anställd.", "vacation_info_link": "Hantera anställda", "accounting_heading": "Bokföring", @@ -6854,6 +6857,7 @@ "li_net_deduction_union": "Nettoavdrag — fackavgift", "li_net_deduction_benefit_payment": "Nettoavdrag — förmånsbetalning", "li_net_deduction_other": "Nettoavdrag — övrigt", + "li_oresavrundning": "Öresavrundning", "li_correction": "Korrigering", "li_other": "Övrigt", "error_load_run": "Kunde inte ladda lönekörning", diff --git a/supabase/migrations/20260813143000_salary_net_rounding.sql b/supabase/migrations/20260813143000_salary_net_rounding.sql new file mode 100644 index 00000000..0369772a --- /dev/null +++ b/supabase/migrations/20260813143000_salary_net_rounding.sql @@ -0,0 +1,55 @@ +-- ============================================================================= +-- Öresavrundning av nettolön (round net salary payout up to whole kronor) +-- ============================================================================= +-- +-- Some banks reject salary payment files whose amounts carry öre. When +-- company_settings.salary_net_rounding is on, the salary engine rounds each +-- employee's net payout UP to the nearest whole krona (never down: rounding +-- down would underpay wages) and emits a derived 'oresavrundning' line item +-- carrying the 0-99 öre difference. The line books as a debit on 3740 +-- Öres- och kronutjämning; gross salary, skatteavdrag and arbetsgivaravgifter +-- are untouched, so AGI/KU are unaffected. Off by default: existing companies +-- keep exact-öre payouts. +-- +-- pg-test: skip (column addition + CHECK list extension, no trigger/RPC/RLS). +-- The re-added CHECK is NOT VALID here and validated in 20260813143001 so the +-- existing-row scan runs under SHARE UPDATE EXCLUSIVE instead of the ADD's +-- ACCESS EXCLUSIVE lock (house pattern per DECISIONS.md 2026-07-13; VALIDATE +-- in the same transaction as ADD would be a no-op since the stronger lock is +-- held until commit). The new list is a strict superset of the previous one, +-- so validation cannot fail. + +ALTER TABLE public.company_settings + ADD COLUMN salary_net_rounding boolean NOT NULL DEFAULT false; + +COMMENT ON COLUMN public.company_settings.salary_net_rounding IS + 'Öresavrundning: round each employee''s net salary payout up to whole kronor. The 0-99 öre difference books on 3740 via a derived oresavrundning line item. Off by default.'; + +-- The derived rounding line follows the semesterersattning pattern (the +-- calculator inserts and re-derives it on every calculate), so the line-item +-- CHECK must accept it. +ALTER TABLE public.salary_line_items + DROP CONSTRAINT salary_line_items_item_type_check; + +ALTER TABLE public.salary_line_items + ADD CONSTRAINT salary_line_items_item_type_check + CHECK (item_type IN ( + 'monthly_salary', 'hourly_salary', + 'overtime', 'overtime_50', 'overtime_100', + 'ob_weekday_evening', 'ob_weekend', 'ob_night', 'ob_holiday', + 'bonus', 'commission', + 'gross_deduction_pension', 'gross_deduction_other', + 'benefit_car', 'benefit_housing', 'benefit_meals', + 'benefit_wellness', 'benefit_bike', 'benefit_other', + 'sick_karens', 'sick_day2_14', 'sick_day15_plus', + 'vab', 'parental_leave', 'unpaid_leave', + 'vacation', 'semesterersattning', + 'traktamente_taxfree', 'traktamente_taxable', + 'mileage_taxfree', 'mileage_taxable', + 'net_deduction_advance', 'net_deduction_union', + 'net_deduction_benefit_payment', 'net_deduction_other', + 'oresavrundning', + 'correction', 'other' + )) NOT VALID; + +NOTIFY pgrst, 'reload schema'; diff --git a/supabase/migrations/20260813143001_validate_salary_line_items_item_type.sql b/supabase/migrations/20260813143001_validate_salary_line_items_item_type.sql new file mode 100644 index 00000000..4d3c1f47 --- /dev/null +++ b/supabase/migrations/20260813143001_validate_salary_line_items_item_type.sql @@ -0,0 +1,5 @@ +-- Validate the item_type CHECK re-added NOT VALID in 20260813143000. +-- Kept separate to avoid a full-table scan under the stronger DDL lock. + +ALTER TABLE public.salary_line_items + VALIDATE CONSTRAINT salary_line_items_item_type_check; diff --git a/tests/helpers.ts b/tests/helpers.ts index 4eda84a3..3373f168 100644 --- a/tests/helpers.ts +++ b/tests/helpers.ts @@ -628,6 +628,7 @@ export function makeCompanySettings( preferred_payment_format: 'pain001', salary_pay_day: 25, salary_default_bank: null, + salary_net_rounding: false, logo_url: null, onboarding_step: 6, onboarding_complete: true, diff --git a/types/index.ts b/types/index.ts index c31ab68a..57b0a76a 100644 --- a/types/index.ts +++ b/types/index.ts @@ -452,6 +452,9 @@ export interface CompanySettings { preferred_payment_format: 'bg_lb' | 'pain001' salary_pay_day: number salary_default_bank: 'swedbank' | 'seb' | 'handelsbanken' | 'nordea' | 'other' | null + // Öresavrundning (migration 20260813143000): round each net payout up to + // whole kronor; the 0-99 öre diff books on 3740 via a derived line item. + salary_net_rounding: boolean // Sandbox is_sandbox: boolean @@ -4235,6 +4238,7 @@ export type SalaryLineItemType = | 'mileage_taxfree' | 'mileage_taxable' | 'net_deduction_advance' | 'net_deduction_union' | 'net_deduction_benefit_payment' | 'net_deduction_other' + | 'oresavrundning' | 'correction' | 'other' export type ShiftPremiumItemType =