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
+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) ?? {},
}
})