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
@@ -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',
|
||||
}
|
||||
|
||||
@@ -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')}>
|
||||
|
||||
@@ -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}`
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
@@ -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,
|
||||
|
||||
@@ -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 =
|
||||
|
||||
Reference in New Issue
Block a user