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
@@ -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)
})
})