feat(supplier-invoices): sarskild loneskatt (SLP) pair on pension premium lines (#1534)

* feat(supplier-invoices): sarskild loneskatt (SLP) pair on pension premium lines

Booking a tjanstepension invoice (e.g. Avanza) needs the buyer's own SLP
beyond the payable: debit 7533 / credit 2514 at 24.26% of the premium
(SLF 1991:687). The item-based debit-only form could not express the
self-balancing pair, so users had to hand-edit the verifikat.

- new leaf module lib/bookkeeping/slp-lines.ts: SLP_RATE (single source,
  re-exported by the bokslut calculator), isSlpPensionAccount (741x),
  generateSlpLines (7533 D / 2514 K, nets to zero)
- migration adds supplier_invoice_items.apply_slp boolean default false
- registration, cash, and privately-paid generators inject the pair for
  flagged 741x items, mirroring the reverse-charge injection; the balance
  guarantees keep 2440/1930/2893 at exactly the invoice total; the credit
  note generator reverses the pair (7533 K / 2514 D)
- privately-paid balance guarantee now subtracts existing credits so the
  SLP 2514 leg never inflates the owner account
- schema field apply_slp + guards in all create paths (main route, inbox
  convert, v1 REST, pending-operations executor): 400
  SI_CREATE_SLP_INVALID_ACCOUNT on non-741x accounts, 400
  SI_CREATE_SLP_ACCRUAL combined with periodisering
- form: advisory hint on unflagged 741x rows with one-click opt-in and a
  quiet confirmation line when applied; totals box untouched (the invoice
  total stays the payable); AB review preview injects the same pair via
  the same generator for parity
- year-end double-count guard: calculateSarskildLoneskatt subtracts SLP
  already posted to 7533 during the year (floored at zero) so bokslut
  never provisions flagged premiums twice

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* chore(api-skill): regenerate suppliers reference for apply_slp

The apiskill:check CI gate requires the generated accounted-api skill to
stay in sync with the endpoint registry after the apply_slp addition.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(slp): carry apply_slp through v1 routes, MCP staging, preview and credit reversal

Review findings on the SLP PR:

- v1 credit route: SI_FULL_COLUMNS now projects items.apply_slp, so
  createSupplierCreditNoteEntry sees the flag and reverses the 7533/2514
  pair booked at registration (it previously stood forever and the
  year-end netting under-provisioned). The flag is also copied onto the
  created credit-note items for parity with the web credit route.
- v1 mark-paid: the items sub-select now includes apply_slp, so a
  kontantmetoden payment via v1 books the cash entry WITH the SLP pair,
  matching the web mark-paid.
- v1 GET ?expand=items: SI_ITEM_COLUMNS includes apply_slp so the flag
  is readable back through the public API.
- credit-note SLP base is abs of the SIGNED sum of flagged line_totals,
  not per-item abs: a mixed-sign flagged original (+10000/-2000) booked
  SLP on 8000 at registration and now reverses exactly that, not 12000.
  The expense-bucket per-item abs convention is untouched.
- kontantmetod bank-match preview appends the same generateSlpLines pair
  the POST books, so the approved lines equal the committed lines.
- MCP gnubok_create_supplier_invoice_from_inbox: line_overrides accepts
  apply_slp (optional boolean), plumbs it into the staged operation's
  items, and rejects non-741x resolved accounts at staging time with the
  bilingual SI_CREATE_SLP_INVALID_ACCOUNT texts.
- DECISIONS.md: five entries for today's decisions.

Every behavioral fix has a test verified to fail without it.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Jakob Wennberg
2026-08-12 20:52:47 +02:00
committed by GitHub
co-authored by Claude Fable 5 Jakob Wennberg
parent 3a7688c163
commit 7cf0e34434
31 changed files with 1329 additions and 27 deletions
+17
View File
@@ -927,6 +927,23 @@ describe('CreateSupplierInvoiceItemSchema', () => {
expect(result.success).toBe(true)
})
it('accepts apply_slp as an optional boolean (särskild löneskatt opt-in)', () => {
const flagged = CreateSupplierInvoiceItemSchema.safeParse(
validSupplierInvoiceItem({ account_number: '7412', vat_rate: 0, apply_slp: true })
)
expect(flagged.success).toBe(true)
const omitted = CreateSupplierInvoiceItemSchema.safeParse(validSupplierInvoiceItem())
expect(omitted.success).toBe(true)
})
it('rejects non-boolean apply_slp', () => {
const result = CreateSupplierInvoiceItemSchema.safeParse(
validSupplierInvoiceItem({ apply_slp: 'yes' })
)
expect(result.success).toBe(false)
})
it('works with quantity * unit_price line total', () => {
const overByABit = CreateSupplierInvoiceItemSchema.safeParse(
validSupplierInvoiceItem({
+6
View File
@@ -977,6 +977,12 @@ export const CreateSupplierInvoiceItemSchema = z.object({
message: 'reverse_charge_rate must be 0.06, 0.12, or 0.25',
})
.optional(),
// Särskild löneskatt på pensionskostnader (SLP): when true the booking
// engine injects a self-balancing 7533 D / 2514 K pair at 24.26 % of the
// line total (lib/bookkeeping/slp-lines.ts). The pair never changes the
// payable. Routes reject the flag on non-741x accounts and in combination
// with the periodisering fields below.
apply_slp: z.boolean().optional(),
vat_code: z.string().optional(),
quantity: z.number().optional(),
unit: z.string().optional(),
@@ -4,13 +4,18 @@ import {
SLP_RATE,
} from '../tax-provision/sarskild-loneskatt-calculator'
function makeSupabaseWithPensionLines(rows: Array<{ debit_amount: number; credit_amount: number }>) {
function makeSupabaseWithPensionLines(
rows: Array<{ account_number?: string; debit_amount: number; credit_amount: number }>,
) {
// The calculator uses the two-step entry-lines fetch
// (lib/bookkeeping/entry-lines.ts): call 1 reads journal_entries, call 2
// reads journal_entry_lines for those entry ids.
// reads journal_entry_lines for those entry ids. One line query covers both
// 7410-7419 (the base) and 7533 (SLP already posted); rows default to a
// pension account when the fixture omits account_number.
const withAccounts = rows.map((row) => ({ account_number: '7410', ...row }))
const responses: Array<{ data: unknown; error: unknown }> = [
{ data: [{ id: 'entry-1' }], error: null },
{ data: rows, error: null },
{ data: withAccounts, error: null },
]
let call = 0
const makeBuilder = () => {
@@ -74,6 +79,53 @@ describe('calculateSarskildLoneskatt', () => {
expect(result!.amount).toBe(9_704)
})
it('subtracts SLP already posted to 7533 during the year (apply_slp on supplier invoices)', async () => {
// 100 000 kr premies booked during the year: 40 000 kr of them were
// flagged apply_slp, so 40 000 × 0.2426 = 9 704 kr already sits on 7533.
// The year-end proposal must cover ONLY the remaining 60 000 kr.
const supabase = makeSupabaseWithPensionLines([
{ account_number: '7412', debit_amount: 100_000, credit_amount: 0 },
{ account_number: '7533', debit_amount: 9_704, credit_amount: 0 },
])
const result = await calculateSarskildLoneskatt(supabase, 'co', 'fp')
expect(result).not.toBeNull()
// 100_000 × 0.2426 − 9_704 = 24_260 − 9_704 = 14_556
expect(result!.amount).toBe(14_556)
const computation = result!.computation as { slpAlreadyPosted: number; base: number }
expect(computation.slpAlreadyPosted).toBe(9_704)
// Posted 7533 never shrinks the SLP BASE, only the proposal.
expect(computation.base).toBe(100_000)
})
it('returns null when the year is already fully provisioned', async () => {
const supabase = makeSupabaseWithPensionLines([
{ account_number: '7412', debit_amount: 10_000, credit_amount: 0 },
{ account_number: '7533', debit_amount: 2_426, credit_amount: 0 },
])
const result = await calculateSarskildLoneskatt(supabase, 'co', 'fp')
expect(result).toBeNull()
})
it('floors at zero when 7533 exceeds the computed SLP (never a negative disposition)', async () => {
const supabase = makeSupabaseWithPensionLines([
{ account_number: '7412', debit_amount: 10_000, credit_amount: 0 },
{ account_number: '7533', debit_amount: 5_000, credit_amount: 0 },
])
const result = await calculateSarskildLoneskatt(supabase, 'co', 'fp')
expect(result).toBeNull()
})
it('nets 7533 credits (a stornoed SLP pair does not count as provisioned)', async () => {
const supabase = makeSupabaseWithPensionLines([
{ account_number: '7412', debit_amount: 10_000, credit_amount: 0 },
{ account_number: '7533', debit_amount: 2_426, credit_amount: 0 },
{ account_number: '7533', debit_amount: 0, credit_amount: 2_426 },
])
const result = await calculateSarskildLoneskatt(supabase, 'co', 'fp')
expect(result).not.toBeNull()
expect(result!.amount).toBe(2_426)
})
it('exposes the SLP rate constant', () => {
expect(SLP_RATE).toBe(0.2426)
})
@@ -1,9 +1,13 @@
import type { SupabaseClient } from '@supabase/supabase-js'
import { fetchEntryLines, type EntryLinesQuery } from '@/lib/bookkeeping/entry-lines'
import { SLP_RATE } from '@/lib/bookkeeping/slp-lines'
import { roundOre } from '@/lib/money'
import type { ProposedDisposition } from '../types'
/** Särskild löneskatt på pensionskostnader (SLP). 24.26 % per SLF 1991:687. */
export const SLP_RATE = 0.2426
// Single source for the 24.26 % rate: lib/bookkeeping/slp-lines.ts (shared
// with the supplier-invoice booking engine's per-line SLP pair). Re-exported
// so existing imports from this module keep working.
export { SLP_RATE }
export interface SlpComputation {
/** Total pension cost during the period: sum of posted debits on accounts
@@ -15,6 +19,10 @@ export interface SlpComputation {
/** Base for SLP = pensionCostsBooked + manualAdjustment. */
base: number
rate: number
/** SLP already posted to 7533 during the period (net of credits), e.g. by
* supplier-invoice lines flagged apply_slp. Subtracted from the proposal
* so bokslut never provisions the same premiums twice. */
slpAlreadyPosted: number
slpAmount: number
}
@@ -36,8 +44,16 @@ export async function calculateSarskildLoneskatt(
fiscalPeriodId: string,
options: { manualAdjustment?: number } = {},
): Promise<ProposedDisposition | null> {
type Row = { debit_amount: number | string | null; credit_amount: number | string | null }
// Two-step entry-lines fetch (see lib/bookkeeping/entry-lines.ts).
type Row = {
account_number: string | null
debit_amount: number | string | null
credit_amount: number | string | null
}
// Two-step entry-lines fetch (see lib/bookkeeping/entry-lines.ts). One
// query covers both the SLP base (7410-7419) and the SLP already posted to
// 7533 during the year (supplier-invoice lines flagged apply_slp book the
// 7533/2514 pair at registration); the rows are partitioned below.
const PENSION_ACCOUNTS = Array.from({ length: 10 }, (_, i) => `741${i}`)
let data: Row[]
try {
data = await fetchEntryLines<Row>({
@@ -49,7 +65,7 @@ export async function calculateSarskildLoneskatt(
.eq('fiscal_period_id', fiscalPeriodId)
.eq('status', 'posted'),
filterLines: (q: EntryLinesQuery) =>
q.gte('account_number', '7410').lte('account_number', '7419'),
q.in('account_number', [...PENSION_ACCOUNTS, '7533']),
attachEntriesAs: null,
})
} catch (err) {
@@ -58,20 +74,32 @@ export async function calculateSarskildLoneskatt(
)
}
const pensionCostsBooked = data.reduce((sum, row) => {
// Cost account: normal balance is debit, so net = debit − credit.
return sum + ((Number(row.debit_amount) || 0) - (Number(row.credit_amount) || 0))
}, 0)
// Net = debit − credit (cost accounts have normal debit balance).
const netDebit = (rows: Row[]) =>
rows.reduce(
(sum, row) => sum + ((Number(row.debit_amount) || 0) - (Number(row.credit_amount) || 0)),
0,
)
const pensionCostsBooked = netDebit(
data.filter((row) => row.account_number != null && row.account_number !== '7533'),
)
// Double-count guard: premiums flagged apply_slp on supplier invoices have
// already booked their 7533/2514 pair during the year. Those debits sit on
// 7533 in this same fiscal period, so subtracting them leaves exactly the
// unprovisioned remainder. Floored at zero: an over-provisioned year never
// proposes a negative disposition.
const slpAlreadyPosted = netDebit(data.filter((row) => row.account_number === '7533'))
const manualAdjustment = options.manualAdjustment ?? 0
const base = Math.max(0, pensionCostsBooked + manualAdjustment)
const slpAmount = Math.round(base * SLP_RATE)
const slpAmount = Math.max(0, Math.round(base * SLP_RATE - slpAlreadyPosted))
const computation: SlpComputation = {
pensionCostsBooked: Math.round(pensionCostsBooked * 100) / 100,
manualAdjustment,
base,
rate: SLP_RATE,
slpAlreadyPosted: roundOre(slpAlreadyPosted),
slpAmount,
}
@@ -89,7 +117,10 @@ export async function calculateSarskildLoneskatt(
account_number: '7533',
debit_amount: slpAmount,
credit_amount: 0,
line_description: `SLP 24,26 % på ${base} kr pensionskostnader`,
line_description:
slpAlreadyPosted > 0
? `SLP 24,26 % på ${base} kr pensionskostnader, minus ${computation.slpAlreadyPosted} kr redan bokförd SLP`
: `SLP 24,26 % på ${base} kr pensionskostnader`,
},
{
account_number: '2514',
@@ -0,0 +1,67 @@
import { describe, it, expect } from 'vitest'
import { SLP_RATE, generateSlpLines, isSlpPensionAccount } from '../slp-lines'
describe('SLP_RATE', () => {
it('is the statutory 24.26 % (SLF 1991:687)', () => {
expect(SLP_RATE).toBe(0.2426)
})
})
describe('isSlpPensionAccount', () => {
it('accepts the whole 7410-7419 range', () => {
for (let i = 0; i <= 9; i++) {
expect(isSlpPensionAccount(`741${i}`)).toBe(true)
}
})
it('rejects everything outside the range', () => {
for (const account of ['7400', '7420', '7533', '2514', '6200', '741', '74100', '']) {
expect(isSlpPensionAccount(account)).toBe(false)
}
})
})
describe('generateSlpLines', () => {
it('builds the 7533 D / 2514 K pair at 24.26 % (Avanza case: 10 000 kr premie)', () => {
const lines = generateSlpLines(10000)
expect(lines).toHaveLength(2)
const [debit, credit] = lines
expect(debit.account_number).toBe('7533')
expect(debit.debit_amount).toBe(2426)
expect(debit.credit_amount).toBe(0)
expect(credit.account_number).toBe('2514')
expect(credit.credit_amount).toBe(2426)
expect(credit.debit_amount).toBe(0)
expect(debit.line_description).toBe('Särskild löneskatt på pensionskostnader (24,26 %)')
})
it('nets to zero (never moves the payable)', () => {
const lines = generateSlpLines(1234.56)
const debits = lines.reduce((s, l) => s + l.debit_amount, 0)
const credits = lines.reduce((s, l) => s + l.credit_amount, 0)
expect(debits).toBe(credits)
expect(debits).toBeGreaterThan(0)
})
it('rounds to öre with Math.round semantics', () => {
// 1000.01 × 0.2426 = 242.602426 → 242.60
const lines = generateSlpLines(1000.01)
expect(lines[0].debit_amount).toBe(242.6)
// 103 × 0.2426 = 24.9878 → 24.99
expect(generateSlpLines(103)[0].debit_amount).toBe(24.99)
})
it('returns [] for zero and negative bases', () => {
expect(generateSlpLines(0)).toEqual([])
expect(generateSlpLines(-5000)).toEqual([])
})
it('returns [] when rounding produces a zero amount', () => {
// 0.01 × 0.2426 = 0.002426 → rounds to 0.00: no zero-amount lines, the
// engine requires every posted line amount > 0 on one side.
expect(generateSlpLines(0.01)).toEqual([])
})
})
@@ -2195,3 +2195,244 @@ describe('dimensions propagation (PR7): createSupplierCreditNoteEntry', () => {
assertBalanced(input)
})
})
// ============================================================
// Särskild löneskatt på pensionskostnader (SLP) pair injection
// ============================================================
describe('SLP pair injection (apply_slp)', () => {
beforeEach(() => {
vi.clearAllMocks()
mockedFindFiscalPeriod.mockResolvedValue('period-1')
})
// The customer case verbatim: Avanza tjänstepension, 10 000 kr premie.
// 2440 K 10 000 / 7412 D 10 000 / 7533 D 2 426 / 2514 K 2 426.
it('registration books the 4-line Avanza case: 7533/2514 nets to zero, payable untouched', async () => {
const invoice = makeSupplierInvoice({
subtotal: 10000,
vat_amount: 0,
total: 10000,
})
const items = [
makeItem({ line_total: 10000, account_number: '7412', vat_rate: 0, apply_slp: true }),
]
await createSupplierInvoiceRegistrationEntry(
null as never, 'company-1', 'user-1', invoice, items, 'swedish_business'
)
const input = mockedCreateEntry.mock.calls[0][3]
expect(input.lines).toHaveLength(4)
expect(findByAccount(input.lines, '7412')[0].debit_amount).toBe(10000)
expect(findByAccount(input.lines, '7533')[0].debit_amount).toBe(2426)
expect(findByAccount(input.lines, '2514')[0].credit_amount).toBe(2426)
// The pair nets to zero: 2440 stays at exactly the invoice total.
expect(findByAccount(input.lines, '2440')[0].credit_amount).toBe(10000)
assertBalanced(input)
})
it('multiple flagged 741x rows aggregate into ONE 7533/2514 pair', async () => {
const invoice = makeSupplierInvoice({ subtotal: 15000, vat_amount: 0, total: 15000 })
const items = [
makeItem({ id: 'i1', line_total: 10000, account_number: '7412', vat_rate: 0, apply_slp: true }),
makeItem({ id: 'i2', line_total: 5000, account_number: '7410', vat_rate: 0, apply_slp: true }),
]
await createSupplierInvoiceRegistrationEntry(
null as never, 'company-1', 'user-1', invoice, items, 'swedish_business'
)
const input = mockedCreateEntry.mock.calls[0][3]
const slpDebit = findByAccount(input.lines, '7533')
expect(slpDebit).toHaveLength(1)
// 15 000 × 0.2426 = 3 639
expect(slpDebit[0].debit_amount).toBe(3639)
expect(findByAccount(input.lines, '2514')[0].credit_amount).toBe(3639)
expect(findByAccount(input.lines, '2440')[0].credit_amount).toBe(15000)
assertBalanced(input)
})
it('rounds the pair to öre (base 1 000,01 → 242,60)', async () => {
const invoice = makeSupplierInvoice({ subtotal: 1000.01, vat_amount: 0, total: 1000.01 })
const items = [
makeItem({ line_total: 1000.01, account_number: '7412', vat_rate: 0, apply_slp: true }),
]
await createSupplierInvoiceRegistrationEntry(
null as never, 'company-1', 'user-1', invoice, items, 'swedish_business'
)
const input = mockedCreateEntry.mock.calls[0][3]
expect(findByAccount(input.lines, '7533')[0].debit_amount).toBe(242.6)
expect(findByAccount(input.lines, '2514')[0].credit_amount).toBe(242.6)
expect(findByAccount(input.lines, '2440')[0].credit_amount).toBe(1000.01)
assertBalanced(input)
})
it('ignores apply_slp on a non-741x account (defense in depth behind the route guard)', async () => {
const invoice = makeSupplierInvoice({ subtotal: 8000, vat_amount: 2000, total: 10000 })
const items = [
makeItem({ line_total: 8000, account_number: '6200', vat_rate: 0.25, apply_slp: true }),
]
await createSupplierInvoiceRegistrationEntry(
null as never, 'company-1', 'user-1', invoice, items, 'swedish_business'
)
const input = mockedCreateEntry.mock.calls[0][3]
expect(findByAccount(input.lines, '7533')).toHaveLength(0)
expect(findByAccount(input.lines, '2514')).toHaveLength(0)
assertBalanced(input)
})
it('unflagged 741x rows book without the pair (opt-in, never automatic)', async () => {
const invoice = makeSupplierInvoice({ subtotal: 10000, vat_amount: 0, total: 10000 })
const items = [
makeItem({ line_total: 10000, account_number: '7412', vat_rate: 0 }),
]
await createSupplierInvoiceRegistrationEntry(
null as never, 'company-1', 'user-1', invoice, items, 'swedish_business'
)
const input = mockedCreateEntry.mock.calls[0][3]
expect(findByAccount(input.lines, '7533')).toHaveLength(0)
expect(findByAccount(input.lines, '2514')).toHaveLength(0)
expect(findByAccount(input.lines, '2440')[0].credit_amount).toBe(10000)
assertBalanced(input)
})
it('SLP lines carry the invoice default dimensions like the RC pairs do', async () => {
const invoice = makeSupplierInvoice({
subtotal: 10000,
vat_amount: 0,
total: 10000,
default_dimensions: { '1': 'KS01' },
})
const items = [
makeItem({ line_total: 10000, account_number: '7412', vat_rate: 0, apply_slp: true }),
]
await createSupplierInvoiceRegistrationEntry(
null as never, 'company-1', 'user-1', invoice, items, 'swedish_business'
)
const input = mockedCreateEntry.mock.calls[0][3]
expect(findByAccount(input.lines, '7533')[0].dimensions).toEqual({ '1': 'KS01' })
expect(findByAccount(input.lines, '2514')[0].dimensions).toEqual({ '1': 'KS01' })
})
it('kontantmetoden: pair injected and the bank credit stays at the invoice total', async () => {
const invoice = makeSupplierInvoice({ subtotal: 10000, vat_amount: 0, total: 10000 })
const items = [
makeItem({ line_total: 10000, account_number: '7412', vat_rate: 0, apply_slp: true }),
]
await createSupplierInvoiceCashEntry(
null as never, 'company-1', 'user-1', invoice, items, '2024-07-01', 'swedish_business'
)
const input = mockedCreateEntry.mock.calls[0][3]
expect(findByAccount(input.lines, '7533')[0].debit_amount).toBe(2426)
expect(findByAccount(input.lines, '2514')[0].credit_amount).toBe(2426)
// Payment guarantee: the bank movement equals the invoice total exactly.
expect(findByAccount(input.lines, '1930')[0].credit_amount).toBe(10000)
assertBalanced(input)
})
it('privately paid (eget utlägg): pair injected and the owner account stays at the invoice total', async () => {
const invoice = makeSupplierInvoice({ subtotal: 10000, vat_amount: 0, total: 10000 })
const items = [
makeItem({ line_total: 10000, account_number: '7412', vat_rate: 0, apply_slp: true }),
]
await createSupplierInvoicePrivatelyPaidEntry(
null as never, 'company-1', 'user-1', invoice, items, 'aktiebolag'
)
const input = mockedCreateEntry.mock.calls[0][3]
expect(findByAccount(input.lines, '7533')[0].debit_amount).toBe(2426)
expect(findByAccount(input.lines, '2514')[0].credit_amount).toBe(2426)
// The SLP pair must not inflate what the owner is owed.
expect(findByAccount(input.lines, '2893')[0].credit_amount).toBe(10000)
assertBalanced(input)
})
it('credit note reverses the pair (7533 K / 2514 D) and keeps 2440 at the invoice total', async () => {
const creditNote = makeSupplierInvoice({
is_credit_note: true,
subtotal: -10000,
vat_amount: 0,
total: -10000,
})
// The caller passes the ORIGINAL invoice's items (positive line_total).
const items = [
makeItem({ line_total: 10000, account_number: '7412', vat_rate: 0, apply_slp: true }),
]
await createSupplierCreditNoteEntry(
null as never, 'company-1', 'user-1', creditNote, items, 'swedish_business'
)
const input = mockedCreateEntry.mock.calls[0][3]
// Swapped sides vs registration.
expect(findByAccount(input.lines, '7533')[0].credit_amount).toBe(2426)
expect(findByAccount(input.lines, '7533')[0].debit_amount).toBe(0)
expect(findByAccount(input.lines, '2514')[0].debit_amount).toBe(2426)
expect(findByAccount(input.lines, '2514')[0].credit_amount).toBe(0)
// Pair nets to zero: 2440 debit clears exactly the original payable.
expect(findByAccount(input.lines, '2440')[0].debit_amount).toBe(10000)
assertBalanced(input)
})
it('mixed-sign flagged original: credit note reverses SLP on the signed sum, not per-item abs', async () => {
// Registration computed its SLP base as the SIGNED sum of flagged
// line_totals: +10000 premium and -2000 rebate booked SLP on 8000
// (1 940,80). Per-item abs on the credit note would reverse SLP on
// 12000 (2 911,20), striking more 7533/2514 than was ever posted.
const creditNote = makeSupplierInvoice({
is_credit_note: true,
subtotal: -8000,
vat_amount: 0,
total: -8000,
})
const items = [
makeItem({ id: 'i1', line_total: 10000, account_number: '7412', vat_rate: 0, apply_slp: true }),
makeItem({ id: 'i2', line_total: -2000, account_number: '7412', vat_rate: 0, apply_slp: true }),
]
await createSupplierCreditNoteEntry(
null as never, 'company-1', 'user-1', creditNote, items, 'swedish_business'
)
const input = mockedCreateEntry.mock.calls[0][3]
// 8 000 × 0.2426 = 1 940,80: exactly what registration booked.
expect(findByAccount(input.lines, '7533')[0].credit_amount).toBe(1940.8)
expect(findByAccount(input.lines, '2514')[0].debit_amount).toBe(1940.8)
assertBalanced(input)
})
it('foreign-currency invoice books the pair in SEK at the invoice rate', async () => {
const invoice = makeSupplierInvoice({
subtotal: 1000, vat_amount: 0, total: 1000,
currency: 'EUR', exchange_rate: 11.50,
})
const items = [
makeItem({ line_total: 1000, account_number: '7412', vat_rate: 0, apply_slp: true }),
]
await createSupplierInvoiceRegistrationEntry(
null as never, 'company-1', 'user-1', invoice, items, 'swedish_business'
)
const input = mockedCreateEntry.mock.calls[0][3]
// 11 500 SEK × 0.2426 = 2 789.90
expect(findByAccount(input.lines, '7533')[0].debit_amount).toBe(2789.9)
expect(findByAccount(input.lines, '2514')[0].credit_amount).toBe(2789.9)
expect(findByAccount(input.lines, '2440')[0].credit_amount).toBe(11500)
assertBalanced(input)
})
})
+54
View File
@@ -0,0 +1,54 @@
// Särskild löneskatt på pensionskostnader (SLP), SLF 1991:687.
//
// Leaf module (only lib/money + types) so the booking engine, the API routes,
// the client-side supplier-invoice form/preview, and the bokslut calculator
// can all share the rate and the line pair without dragging server-only
// dependencies into client bundles.
import { roundOre } from '@/lib/money'
import type { CreateJournalEntryLineInput } from '@/types'
/** Särskild löneskatt på pensionskostnader. 24.26 % per SLF 1991:687. */
export const SLP_RATE = 0.2426
/**
* Accounts whose costs carry SLP when flagged on a supplier invoice line:
* BAS 7410-7419 (pensionsförsäkringspremier m.m.). The same range the
* bokslut calculator reads as the year-end SLP base
* (lib/bokslut/tax-provision/sarskild-loneskatt-calculator.ts).
*/
export function isSlpPensionAccount(accountNumber: string): boolean {
return /^741\d$/.test(accountNumber)
}
/**
* Build the self-balancing SLP pair for a pension-premium base (SEK):
*
* Debit 7533 Särskild löneskatt för pensionskostnader [base x 24.26 %]
* Credit 2514 Beräknad särskild löneskatt pensionskostnader [same amount]
*
* The pair nets to zero, so injecting it into a supplier-invoice verifikat
* never moves the payable (2440): the same mechanism reverse-charge fiktiv
* moms uses to book beyond the payable. Returns [] for a non-positive base
* or when rounding produces a zero amount.
*/
export function generateSlpLines(baseSek: number): CreateJournalEntryLineInput[] {
if (baseSek <= 0) return []
const amount = roundOre(baseSek * SLP_RATE)
if (amount <= 0) return []
const desc = 'Särskild löneskatt på pensionskostnader (24,26 %)'
return [
{
account_number: '7533',
debit_amount: amount,
credit_amount: 0,
line_description: desc,
},
{
account_number: '2514',
debit_amount: 0,
credit_amount: amount,
line_description: desc,
},
]
}
+85 -2
View File
@@ -8,6 +8,7 @@ import {
isReverseChargeBasisAccount,
resolveReverseChargeRate,
} from './vat-entries'
import { generateSlpLines, isSlpPensionAccount } from './slp-lines'
import {
coerceDimensionsBag,
dimensionsBagKey,
@@ -232,6 +233,18 @@ export async function createSupplierInvoiceRegistrationEntry(
}
}
// Särskild löneskatt på pensionskostnader (SLP, 24.26 %): items flagged
// apply_slp (tjänstepensionspremier on 741x) get a self-balancing
// 7533 D / 2514 K pair, injected the same way the reverse-charge pairs are.
// The pair nets to zero, so the 2440 balance guarantee below keeps the
// payable at the invoice total: SLP is the buyer's own tax, never part of
// the supplier's fordran.
const slpBase = slpBaseSek(items, invoice.currency, invoice.exchange_rate)
if (slpBase > 0) {
const slpLines = generateSlpLines(slpBase)
lines.push(...slpLines.map((l) => ({ ...l, dimensions: defaultDimensions })))
}
// Credit: Leverantörsskulder, balance guarantee: ensures sum(debits) === sum(credits)
// For reverse charge, intermediate credits (2614/2624/2634) already exist, so we subtract them
const totalDebits = lines.reduce((sum, l) => sum + l.debit_amount, 0)
@@ -491,6 +504,16 @@ export async function createSupplierInvoiceCashEntry(
}
}
// Särskild löneskatt på pensionskostnader (SLP): same self-balancing
// 7533 D / 2514 K pair as the registration entry, at the effective rate so
// the base stays consistent with the expense lines above. Nets to zero:
// the payment-account credit below is untouched.
const slpBase = slpBaseSek(items, invoice.currency, effectiveRate)
if (slpBase > 0) {
const slpLines = generateSlpLines(slpBase)
lines.push(...slpLines.map((l) => ({ ...l, dimensions: defaultDimensions })))
}
// Öresavrundning: when translating a foreign invoice at the payment-date
// rate, per-line rounding can drift the implied bank total by an öre or two.
// Fold that residual into the largest expense line so the payment-account
@@ -603,12 +626,24 @@ export async function createSupplierInvoicePrivatelyPaidEntry(
}
}
// Credit: Owner payable/equity, balance guarantee
// Särskild löneskatt på pensionskostnader (SLP): same self-balancing
// 7533 D / 2514 K pair as the registration entry. Nets to zero, so the
// owner account below still carries exactly the expense + VAT total.
const slpBase = slpBaseSek(items, invoice.currency, invoice.exchange_rate)
if (slpBase > 0) {
const slpLines = generateSlpLines(slpBase)
lines.push(...slpLines.map((l) => ({ ...l, dimensions: defaultDimensions })))
}
// Credit: Owner payable/equity, balance guarantee. Existing credits (the
// SLP 2514 leg) are subtracted so the pair never inflates what the owner
// is owed: same guarantee shape as the registration entry's 2440 line.
const totalDebits = lines.reduce((sum, l) => sum + l.debit_amount, 0)
const totalCredits = lines.reduce((sum, l) => sum + l.credit_amount, 0)
lines.push({
account_number: ownerAccount,
debit_amount: 0,
credit_amount: Math.round(totalDebits * 100) / 100,
credit_amount: Math.round((totalDebits - totalCredits) * 100) / 100,
line_description: desc,
dimensions: defaultDimensions,
})
@@ -753,6 +788,30 @@ export async function createSupplierCreditNoteEntry(
}
}
// Reverse the SLP pair: registration booked 7533 D / 2514 K, so the credit
// note books 7533 K / 2514 D (same debit/credit swap as basbeloppsraderna
// above). Items are the ORIGINAL invoice's, so apply_slp reverses against
// the same base. Nets to zero: the 2440 debit guarantee is untouched.
//
// The base is abs of the SIGNED sum, not the per-item abs the expense
// buckets use: registration computed its SLP base as the signed sum, so a
// mixed-sign flagged original (+10000 premium and -2000 rebate) booked SLP
// on 8000. Per-item abs here would reverse SLP on 12000, striking more
// 7533/2514 than was ever posted. The buckets keep per-item abs because
// each reversal line must land positive on its own side per account.
const slpBase = Math.abs(slpBaseSek(items, creditNote.currency, creditNote.exchange_rate))
if (slpBase > 0) {
for (const line of generateSlpLines(slpBase)) {
lines.push({
account_number: line.account_number,
debit_amount: line.credit_amount,
credit_amount: line.debit_amount,
line_description: line.line_description,
dimensions: defaultDimensions,
})
}
}
lines.push(...creditLines)
// Debit: Leverantörsskulder, balance guarantee: debit = sum of credits minus other debits
@@ -860,6 +919,30 @@ function groupBaseByRate(
return baseByRate
}
/**
* Sum, in SEK, the base for särskild löneskatt på pensionskostnader: items
* flagged apply_slp whose account is a 741x pension-premium account
* (tjänstepensionspremier). The create routes reject apply_slp on any other
* account; the account check here is defense in depth so a tampered or
* legacy row can never SLP-flag an arbitrary expense.
*/
function slpBaseSek(
items: SupplierInvoiceItem[],
currency: string,
exchangeRate: number | null | undefined,
useAbsoluteValues = false
): number {
let base = 0
for (const item of items) {
if (item.apply_slp !== true) continue
if (!isSlpPensionAccount(item.account_number)) continue
let sek = toSekOrThrow(item.line_total, currency, exchangeRate)
if (useAbsoluteValues) sek = Math.abs(sek)
base += sek
}
return base
}
/**
* Sum, per VAT rate, the base (line_total in SEK) of items booked to
* non-basis expense accounts. Items already booked to a 44xx/45xx basis
+14
View File
@@ -2138,6 +2138,20 @@ const SUPPLIER_INVOICE_WAVE4: Record<string, StructuredErrorEntry> = {
message_en:
'Periodisering cannot be combined with reverse charge. The expense line carries the VAT base for the VAT declaration (boxes 20-32), so the net amount cannot be deferred to an interim account.',
},
SI_CREATE_SLP_INVALID_ACCOUNT: {
httpStatus: 400,
message_sv:
'Särskild löneskatt kan bara läggas till på rader med pensionskonto 7410-7419 (t.ex. 7412 Premier för tjänstepensioner). Byt konto på raden eller ta bort löneskatten.',
message_en:
'Särskild löneskatt (payroll tax on pension costs) can only be added on lines booked to a pension account 7410-7419 (e.g. 7412 occupational pension premiums). Change the line account or remove the flag.',
},
SI_CREATE_SLP_ACCRUAL: {
httpStatus: 400,
message_sv:
'Särskild löneskatt kan inte kombineras med periodisering på samma rad. Löneskatten (7533/2514) beräknas på hela radbeloppet vid registrering och kan inte skjutas upp.',
message_en:
'Särskild löneskatt cannot be combined with periodisering on the same line. The payroll tax (7533/2514) is computed on the full line amount at registration and cannot be deferred.',
},
SI_DELETE_HAS_BOOKING: {
httpStatus: 400,
message_sv:
+19
View File
@@ -43,6 +43,7 @@ import {
resolveUnsettledStatus,
} from '@/lib/supplier-invoices/lifecycle'
import { coerceDimensionsBag } from '@/lib/bookkeeping/dimension-resolver'
import { isSlpPensionAccount } from '@/lib/bookkeeping/slp-lines'
import { cancelOrphanedPaymentEntry } from '@/lib/bookkeeping/cancel-orphaned-entry'
import { runWithActor } from '@/lib/bookkeeping/actor-context-node'
import type { CommitActor } from '@/lib/bookkeeping/actor-context'
@@ -3383,6 +3384,21 @@ async function commitCreateSupplierInvoiceFromInbox(
return { error: 'exchange_rate must be a finite number when provided', status: 400 }
}
// Särskild löneskatt (SLP): staged params must respect the same rule the
// create routes enforce; the 7533/2514 pair is only lawful on 741x pension
// premiums, so a flag on any other account is tampered or mis-staged.
const slpInvalid = rawItems.some(
(item) => item.apply_slp === true && !isSlpPensionAccount(String(item.account_number ?? '')),
)
if (slpInvalid) {
return {
error:
getErrorEntry('SI_CREATE_SLP_INVALID_ACCOUNT')?.message_sv ??
'Särskild löneskatt kan bara läggas till på rader med pensionskonto 7410-7419.',
status: 400,
}
}
// Idempotency: a re-fired commit (e.g. retry, double-click on the approval
// UI, racy MCP call) must not create a second leverantörsfaktura for the
// same inbox row. The DB FK on invoice_inbox_items.created_supplier_invoice_id
@@ -3566,6 +3582,9 @@ async function commitCreateSupplierInvoiceFromInbox(
reverse_charge_rate: reverseCharge
? ([0.06, 0.12, 0.25].includes(Number(item.reverse_charge_rate)) ? Number(item.reverse_charge_rate) : null)
: null,
// Särskild löneskatt (SLP): booking injects the self-balancing
// 7533/2514 pair for this line. Validated above (741x accounts only).
apply_slp: item.apply_slp === true,
dimensions: coerceDimensionsBag(item.dimensions) ?? {},
}
})