* 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>
68 lines
2.2 KiB
TypeScript
68 lines
2.2 KiB
TypeScript
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([])
|
||
})
|
||
})
|