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
@@ -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',