Files
accounted/lib/bookkeeping/__tests__/slp-lines.test.ts
T
7cf0e34434 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>
2026-08-12 20:52:47 +02:00

68 lines
2.2 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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([])
})
})