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
+2
View File
@@ -956,6 +956,8 @@ One line per decision: `[YYYY-MM-DD] <decision>: <why>`. 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.
@@ -50,6 +50,7 @@ const LINE_ITEM_TYPE_KEYS: Record<SalaryLineItemType, string> = {
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',
}
+31
View File
@@ -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)
})
})
@@ -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<boolean | null>(null)
if (isLoading) return <SettingsLoadingSkeleton />
if (!settings) return <SettingsLoadError onRetry={refetch} />
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() {
<option value="other">{t('bank_other')}</option>
</SettingsSelect>
</SettingsRow>
<SettingsRow label={t('net_rounding_label')} help={t('net_rounding_help')}>
<Switch
id="salary_net_rounding"
checked={effectiveNetRounding}
onCheckedChange={(next) => setNetRounding(next)}
/>
<label htmlFor="salary_net_rounding" className="cursor-pointer text-sm">
{t('net_rounding_toggle')}
</label>
</SettingsRow>
</SettingsGroup>
<SettingsGroup label={t('accounting_heading')}>
+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}`
}
+4
View File
@@ -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",
+4
View File
@@ -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",
@@ -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';
@@ -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;
+1
View File
@@ -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,
+4
View File
@@ -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 =