Files
accounted/components/settings/sections/SalarySettingsContent.tsx
T
MattssonandClaude Fable 5 4bb0655e4a 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>
2026-08-14 00:36:33 +02:00

200 lines
7.7 KiB
TypeScript

'use client'
import Link from 'next/link'
import { useState } from 'react'
import { useTranslations } from 'next-intl'
import { SettingsFormWrapper } from '@/components/settings/SettingsFormWrapper'
import { SettingsLoadError } from '@/components/settings/SettingsLoadError'
import { SettingsLoadingSkeleton } from '@/components/settings/SettingsLoadingSkeleton'
import {
SettingsGroup,
SettingsInput,
SettingsRow,
SettingsSectionHeader,
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'
const SERIES_OPTIONS = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ'.split('')
const BANK_OPTIONS = ['swedbank', 'seb', 'handelsbanken', 'nordea'] as const
const BANK_LABEL: Record<(typeof BANK_OPTIONS)[number], string> = {
swedbank: 'Swedbank',
seb: 'SEB',
handelsbanken: 'Handelsbanken',
nordea: 'Nordea',
}
export function SalarySettingsContent() {
const t = useTranslations('settings_salary')
const tNav = useTranslations('settings_nav')
const tIntro = useTranslations('settings_intro')
const tSalary = useTranslations('salary')
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) {
const payDayRaw = parseInt((formData.get('salary_pay_day') as string) || '25', 10)
const payDay = Number.isFinite(payDayRaw) ? Math.min(28, Math.max(1, payDayRaw)) : 25
const paymentFormat = (formData.get('preferred_payment_format') as string) || 'pain001'
const bank = (formData.get('salary_default_bank') as string) || 'none'
const series = (formData.get('salary_voucher_series') as string) || 'A'
const updates: Record<string, unknown> = {
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;
// salary entries pass run.voucher_series explicitly, seeded from this
// entry at run creation. Merge, never replace, the map so other
// source-type overrides survive.
if (series !== currentSeries) {
updates.default_voucher_series_per_source_type = {
...(settings?.default_voucher_series_per_source_type || {}),
salary_payment: series,
}
}
return {
updates,
onSuccess: (data: Record<string, unknown>) => {
updateSettings(data as Partial<CompanySettings>)
},
}
}
return (
<div>
<SettingsSectionHeader title={tNav('salary')} intro={tIntro('salary')} />
<SettingsFormWrapper onSave={handleSave}>
<SettingsGroup label={t('payments_heading')} help={t('info_payroll_scope')}>
<SettingsRow
label={t('pay_day_label')}
htmlFor="salary_pay_day"
help={t('pay_day_help')}
align="baseline"
>
<SettingsInput
id="salary_pay_day"
name="salary_pay_day"
type="number"
inputMode="numeric"
min={1}
max={28}
defaultValue={settings.salary_pay_day ?? 25}
className="max-w-24 flex-none tabular-nums"
/>
</SettingsRow>
<SettingsRow
label={t('format_label')}
htmlFor="preferred_payment_format"
help={t('format_help')}
borderless={effectiveFormat === 'bg_lb'}
>
<SettingsSelect
id="preferred_payment_format"
name="preferred_payment_format"
value={effectiveFormat}
onChange={(e) => setFormat(e.target.value as 'bg_lb' | 'pain001')}
>
<option value="pain001">{t('format_pain001')}</option>
<option value="bg_lb">{t('format_bg_lb')}</option>
</SettingsSelect>
</SettingsRow>
{effectiveFormat === 'bg_lb' && (
<p className="border-b border-border px-1 pb-3 text-[12.5px] leading-relaxed text-attn">
{t('sunset_warning')}
</p>
)}
<SettingsRow label={t('bank_label')} htmlFor="salary_default_bank" help={t('bank_help')}>
<SettingsSelect
id="salary_default_bank"
name="salary_default_bank"
defaultValue={settings.salary_default_bank ?? 'none'}
>
<option value="none">{t('bank_none')}</option>
{BANK_OPTIONS.map((key) => (
<option key={key} value={key}>{BANK_LABEL[key]}</option>
))}
<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')}>
<SettingsRow
label={t('voucher_series_label')}
htmlFor="salary_voucher_series"
help={t('voucher_series_help')}
>
<SettingsSelect
id="salary_voucher_series"
name="salary_voucher_series"
defaultValue={currentSeries}
className="font-mono"
>
{SERIES_OPTIONS.map((letter) => (
<option key={letter} value={letter}>{letter}</option>
))}
</SettingsSelect>
</SettingsRow>
</SettingsGroup>
</SettingsFormWrapper>
{/* Tax tables: automatic, read-only status. Lives outside the form so
the recheck action never interacts with the save flow. */}
<SettingsGroup
label={t('tax_tables_heading')}
help={t.rich('info_current_year', {
strong: (chunks) => <strong>{chunks}</strong>,
})}
>
<SettingsRow label={tSalary('th_status')} help={t('tax_tables_help')}>
<TaxTableStatus />
</SettingsRow>
</SettingsGroup>
{/* Vacation is configured per employee; this row only points there. */}
<SettingsGroup label={t('vacation_heading')}>
<SettingsRow label={t('vacation_rule_label')} help={t('vacation_info')}>
<Link
href="/salary/employees"
className="text-sm text-muted-foreground underline underline-offset-2 transition-colors duration-150 hover:text-foreground"
>
{t('vacation_info_link')}
</Link>
</SettingsRow>
</SettingsGroup>
</div>
)
}