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
@@ -38,6 +38,8 @@ import {
findReverseChargeAccountWarningRows,
findUnflaggedForeignZeroVatRows,
} from '@/lib/vat/supplier-invoice-line-checks'
import { SLP_RATE, isSlpPensionAccount } from '@/lib/bookkeeping/slp-lines'
import { roundOre } from '@/lib/money'
import { ArrowLeft, Plus, Trash2, ChevronDown, Loader2, Lock, AlertCircle, AlertTriangle, MessageCircle, Link2, CalendarClock, Tags, Paperclip } from 'lucide-react'
import type { Supplier, BASAccount, VatTreatment, EntityType, InvoiceExtractionResult, FiscalPeriod } from '@/types'
@@ -58,6 +60,10 @@ interface LineItem {
// (possibly empty) while the row's dimensions panel is open; the server
// merges it over the invoice's default_dimensions at booking time.
dimensions?: Record<string, string>
// Särskild löneskatt på pensionskostnader: booking injects a self-balancing
// 7533 D / 2514 K pair at 24.26 % of the line amount. Only offered on 741x
// pension-premium accounts; never changes the invoice total.
apply_slp?: boolean
}
// The existing invoice surfaced on a duplicate-number conflict, used to drive
@@ -842,6 +848,11 @@ export default function NewSupplierInvoiceForm({
if (!watchedReverseCharge && defaultRate != null && Number.isFinite(defaultRate)) {
setValue(`items.${index}.vat_rate`, defaultRate, { shouldDirty: true })
}
// Särskild löneskatt only applies to 741x pension premiums: leaving the
// range clears the flag so a stale opt-in can never reach the API.
if (watch(`items.${index}.apply_slp`) && !isSlpPensionAccount(accountNumber)) {
setValue(`items.${index}.apply_slp`, undefined, { shouldDirty: true })
}
}
// Periodisering per rad: kräver faktureringsmetoden; eget utlägg bokar
@@ -889,9 +900,71 @@ export default function NewSupplierInvoiceForm({
suggestBalanceAccount('expense', account),
{ shouldDirty: true },
)
// Periodisering + särskild löneskatt on the same row is rejected by the
// API (SI_CREATE_SLP_ACCRUAL): opening the accrual panel wins and the
// SLP opt-in is cleared (its confirmation row disappears with it).
if (watch(`items.${index}.apply_slp`)) {
setValue(`items.${index}.apply_slp`, undefined, { shouldDirty: true })
}
}
}
// --- Särskild löneskatt på pensionskostnader (SLP, 24,26 %) ---
// A 741x pension-premium row gets an advisory hint (add the 7533/2514
// pair) or, once opted in, a quiet confirmation line. The pair nets to
// zero, so the totals box below is untouched: the invoice total IS the
// payable. Hidden while the row's periodisering panel is open (the API
// rejects the combination).
function slpRowVisible(index: number): boolean {
const item = watchedItems?.[index]
if (!item) return false
if (!isSlpPensionAccount(item.account_number || '')) return false
if (canUseAccrual && isAccrualOpen(index)) return false
return true
}
function setSlp(index: number, value: boolean) {
setValue(`items.${index}.apply_slp`, value ? true : undefined, { shouldDirty: true })
}
function renderSlpPanel(index: number) {
const item = watchedItems?.[index]
if (!item) return null
const slpAmount = roundOre((item.amount || 0) * SLP_RATE)
if (item.apply_slp) {
return (
<div className="flex items-center justify-between gap-2">
<p className="text-xs text-muted-foreground tabular-nums">
{t('slp_applied_line', { amount: formatAmount(slpAmount) })}
</p>
<Button type="button" variant="ghost" size="sm" onClick={() => setSlp(index, false)}>
{t('slp_remove_action')}
</Button>
</div>
)
}
return (
<div
role="status"
className="flex items-start gap-2 rounded-lg border border-warning/30 bg-warning/10 p-3"
>
<AlertTriangle className="h-4 w-4 text-warning-foreground mt-0.5 shrink-0" />
<p className="flex-1 text-sm text-warning-foreground">
{t('slp_hint', { amount: formatAmount(slpAmount) })}
</p>
<Button
type="button"
variant="outline"
size="sm"
className="shrink-0"
onClick={() => setSlp(index, true)}
>
{t('slp_add_action')}
</Button>
</div>
)
}
// --- Dimension tagging (kostnadsställe/projekt, dimensions PR7) ---
// Mirrors the accrual pattern: a defined (possibly empty) bag on the item
// means the row's panel is open; closing clears the bag entirely so the
@@ -1120,6 +1193,14 @@ export default function NewSupplierInvoiceForm({
...(item.dimensions && Object.keys(item.dimensions).length > 0
? { dimensions: item.dimensions }
: {}),
// Särskild löneskatt (SLP): only sent when the opt-in is valid for
// the row (741x account, no periodisering): a stale flag must never
// 400 the submit.
...(item.apply_slp &&
isSlpPensionAccount(item.account_number) &&
!(canUseAccrual && item.accrual_period_start && item.accrual_period_end)
? { apply_slp: true }
: {}),
})),
}
}
@@ -1947,7 +2028,7 @@ export default function NewSupplierInvoiceForm({
<tbody>
{fields.map((field, index) => (
<Fragment key={field.id}>
<tr className={cn('align-top', (canUseAccrual && isAccrualOpen(index)) || (dimensionsEnabled && isDimOpen(index)) ? 'border-0' : 'border-b last:border-0')}>
<tr className={cn('align-top', (canUseAccrual && isAccrualOpen(index)) || (dimensionsEnabled && isDimOpen(index)) || slpRowVisible(index) ? 'border-0' : 'border-b last:border-0')}>
<td className="py-2 pr-2">
<Controller
name={`items.${index}.account_number`}
@@ -2069,12 +2150,19 @@ export default function NewSupplierInvoiceForm({
</tr>
)}
{dimensionsEnabled && isDimOpen(index) && (
<tr className="border-b last:border-0">
<tr className={cn(slpRowVisible(index) ? 'border-0' : 'border-b last:border-0')}>
<td colSpan={6} className="pb-3">
{renderDimensionsPanel(index)}
</td>
</tr>
)}
{slpRowVisible(index) && (
<tr className="border-b last:border-0">
<td colSpan={6} className="pb-3">
{renderSlpPanel(index)}
</td>
</tr>
)}
</Fragment>
))}
</tbody>
@@ -2206,6 +2294,7 @@ export default function NewSupplierInvoiceForm({
{canUseAccrual && isAccrualOpen(index) &&
renderAccrualPanel(index, `accrual-mobile-${index}`)}
{dimensionsEnabled && isDimOpen(index) && renderDimensionsPanel(index)}
{slpRowVisible(index) && renderSlpPanel(index)}
</div>
))}
</div>
@@ -12,6 +12,7 @@ import {
generateReverseChargeBasisLines,
generateReverseChargeLines,
} from '@/lib/bookkeeping/vat-entries'
import { generateSlpLines, isSlpPensionAccount } from '@/lib/bookkeeping/slp-lines'
import { buildSupplierDescription } from '@/lib/bookkeeping/supplier-invoice-description'
import { resolveBookingAccount, itemHasAccrual } from '@/lib/bookkeeping/accruals/account-suggestions'
import type { Supplier } from '@/types'
@@ -27,6 +28,9 @@ interface ReviewLineItem {
// Self-assessed VAT rate for omvänd skattskyldighet (0.06/0.12/0.25). The
// supplier charges no VAT (vat_rate = 0); this drives the fiktiv-moms preview.
reverse_charge_rate?: number
// Särskild löneskatt på pensionskostnader: previews the self-balancing
// 7533 D / 2514 K pair the engine injects for flagged 741x lines.
apply_slp?: boolean
// Periodisering: when both dates are set, the registration entry books the
// net to the 17xx interim account instead of account_number (mirrored via
// resolveBookingAccount so this preview matches the saved verifikat).
@@ -114,6 +118,24 @@ function buildJournalPreview(
? Math.round(item.vat_amount * 100) / 100
: Math.round(item.amount * item.vat_rate * 100) / 100
// Särskild löneskatt på pensionskostnader: same self-balancing pair the
// engine injects (7533 D / 2514 K at 24.26 % of flagged 741x lines), via
// the same generator, so this preview matches the saved verifikat. The
// pair nets to zero and never moves the 2440 credit below.
const slpBase = items.reduce(
(sum, item) =>
item.apply_slp && isSlpPensionAccount(item.account_number)
? sum + toSek(item.amount)
: sum,
0,
)
const slpPreviewLines: JournalPreviewLine[] = generateSlpLines(slpBase).map((sl) => ({
account_number: sl.account_number,
description: sl.line_description ?? sl.account_number,
debit: sl.debit_amount,
credit: sl.credit_amount,
}))
if (reverseCharge) {
// Reverse charge: the supplier charges no VAT, so the buyer self-assesses at
// the Swedish statutory rate (resolveReverseChargeRate: 25% huvudregel
@@ -170,6 +192,8 @@ function buildJournalPreview(
}
}
lines.push(...slpPreviewLines)
// Credit: 2440 at subtotal (no real VAT for reverse charge)
lines.push({
account_number: '2440',
@@ -197,6 +221,8 @@ function buildJournalPreview(
})
}
}
lines.push(...slpPreviewLines)
// Credit: 2440 at total incl. VAT
lines.push({
account_number: '2440',