feat(salary): öresavrundning of net pay to whole kronor (#1609)
* feat(salary): öresavrundning of net pay to whole kronor Some banks reject salary payment files whose amounts carry öre. New company_settings.salary_net_rounding toggle (off by default): the engine rounds each net payout up to the next whole krona, never down, and emits a derived oresavrundning line item (semesterersattning pattern) that debits 3740 Öres- och kronutjämning so the salary entry stays balanced. Gross, tax and avgifter are untouched, so AGI/KU are unaffected. Payment files (pain.001 + Bankgirot LB) get whole-krona amounts via the rounded net_salary. Toggle in salary settings; payslip and run detail show the line item. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(salary): keep employer cost on the shared definition; block manual rounding lines Skeptic findings on the öresavrundning commit: (1) the engine included netRounding in totalEmployerCost while payslip summary, KPI cards and lönejournal recompute the figure from stored columns, printing two different totals on the same payslip; employer cost now stays on the shared definition and the öre cost is carried by the 3740 ledger line. (2) 'oresavrundning' is excluded from the line-item create/update schemas: it is the only item type the booking keeps out of the gross reconciliation, so a manually created row would structurally unbalance the salary verifikat. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(salary): add the item_type CHECK as NOT VALID, validate separately Compliance-swarm finding (SOC 2 CC8.1): the CHECK re-add scanned salary_line_items under the ADD's ACCESS EXCLUSIVE lock. Split per the house pattern (DECISIONS.md 2026-07-13): 20260813143000 re-adds the constraint NOT VALID, new 20260813143001 validates it under SHARE UPDATE EXCLUSIVE in its own transaction. The list is a strict superset of the previous CHECK, so validation cannot fail. Both files are branch-only, so editing in place is within the never-modify-shipped rule. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5
parent
4a9fa5e6c5
commit
4bb0655e4a
@@ -2815,3 +2815,22 @@ describe('CreateRecurringScheduleSchema interval_months', () => {
|
||||
expect(CreateRecurringScheduleSchema.safeParse({ ...base, interval_months: 1.5 }).success).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('CreateSalaryLineItemSchema: derived-only item types', () => {
|
||||
it("rejects manual 'oresavrundning' lines (only the calculator may write them)", async () => {
|
||||
const { CreateSalaryLineItemSchema, UpdateSalaryLineItemSchema } = await import('../schemas')
|
||||
const base = {
|
||||
salary_run_employee_id: '3f0a2f60-0000-4000-8000-000000000001',
|
||||
description: 'Öresavrundning',
|
||||
amount: 0.4,
|
||||
}
|
||||
expect(
|
||||
CreateSalaryLineItemSchema.safeParse({ ...base, item_type: 'oresavrundning' }).success,
|
||||
).toBe(false)
|
||||
expect(
|
||||
CreateSalaryLineItemSchema.safeParse({ ...base, item_type: 'bonus' }).success,
|
||||
).toBe(true)
|
||||
expect(UpdateSalaryLineItemSchema.safeParse({ item_type: 'oresavrundning' }).success).toBe(false)
|
||||
expect(UpdateSalaryLineItemSchema.safeParse({ item_type: 'bonus' }).success).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
+11
-1
@@ -2014,6 +2014,9 @@ export const UpdateSettingsSchema = z.object({
|
||||
.enum(['swedbank', 'seb', 'handelsbanken', 'nordea', 'other'])
|
||||
.nullable()
|
||||
.optional(),
|
||||
// Öresavrundning: round each net payout up to whole kronor (banks that
|
||||
// reject öre in salary payment files). Diff books on 3740.
|
||||
salary_net_rounding: z.boolean().optional(),
|
||||
// Vacation year basis (payroll gap-closure 3.1): sammanfallande calendar
|
||||
// year (default) or the statutory Apr 1 - Mar 31 split. The settings route
|
||||
// blocks changing this while open vacation-ledger rows exist.
|
||||
@@ -2514,6 +2517,7 @@ export const SalaryLineItemTypeSchema = z.enum([
|
||||
'mileage_taxfree', 'mileage_taxable',
|
||||
'net_deduction_advance', 'net_deduction_union', 'net_deduction_benefit_payment',
|
||||
'net_deduction_other',
|
||||
'oresavrundning',
|
||||
'correction', 'other',
|
||||
])
|
||||
|
||||
@@ -2893,7 +2897,13 @@ export const AddEmployeeToRunSchema = z.object({
|
||||
|
||||
export const CreateSalaryLineItemSchema = z.object({
|
||||
salary_run_employee_id: uuid,
|
||||
item_type: SalaryLineItemTypeSchema,
|
||||
// 'oresavrundning' is derived-only: the calculator writes it from the
|
||||
// engine's netRounding and the booking excludes it from the gross
|
||||
// reconciliation, so a manually created row would unbalance the salary
|
||||
// verifikat by exactly its amount (the DB balance trigger then rejects the
|
||||
// booking). Every other derived type is absorbed by the base remainder and
|
||||
// stays harmless to create by hand.
|
||||
item_type: SalaryLineItemTypeSchema.exclude(['oresavrundning']),
|
||||
description: z.string().min(1).max(500),
|
||||
quantity: z.number().optional(),
|
||||
unit_price: z.number().optional(),
|
||||
|
||||
Reference in New Issue
Block a user