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
+26
View File
@@ -44,6 +44,7 @@ import {
import { createSupplierInvoiceRegistrationEntry } from '@/lib/bookkeeping/supplier-invoice-entries'
import { createSchedulesForSupplierInvoice } from '@/lib/bookkeeping/accruals/from-invoices'
import { suggestBalanceAccount } from '@/lib/bookkeeping/accruals/account-suggestions'
import { isSlpPensionAccount } from '@/lib/bookkeeping/slp-lines'
import { createJournalEntry } from '@/lib/bookkeeping/engine'
import { bookkeepingErrorResponse } from '@/lib/bookkeeping/errors'
import { errorResponseFromCode } from '@/lib/errors/get-structured-error'
@@ -1754,6 +1755,28 @@ export const invoiceInboxExtension: Extension = {
return NextResponse.json({ error: 'Supplier not found' }, { status: 404 })
}
// Särskild löneskatt (SLP): same guards as /api/supplier-invoices.
// The 7533/2514 pair is only lawful on 741x pension premiums and
// cannot be combined with periodisering on the same row.
if (
body.items.some(
(bodyItem) => bodyItem.apply_slp && !isSlpPensionAccount(bodyItem.account_number),
)
) {
return errorResponseFromCode('SI_CREATE_SLP_INVALID_ACCOUNT', ctx.log)
}
if (
body.items.some(
(bodyItem) =>
bodyItem.apply_slp &&
(bodyItem.accrual_period_start ||
bodyItem.accrual_period_end ||
bodyItem.accrual_balance_account),
)
) {
return errorResponseFromCode('SI_CREATE_SLP_ACCRUAL', ctx.log)
}
// Periodisering requires faktureringsmetoden: mirror the main
// /api/supplier-invoices guard so kontantmetod companies never store
// accrual fields the booking would silently ignore.
@@ -1840,6 +1863,9 @@ export const invoiceInboxExtension: Extension = {
? (bodyItem.accrual_balance_account ??
suggestBalanceAccount('expense', bodyItem.account_number))
: null,
// Särskild löneskatt (SLP): booking injects the self-balancing
// 7533/2514 pair for this line. Guarded above (741x, no accrual).
apply_slp: bodyItem.apply_slp === true,
}
})
@@ -610,6 +610,69 @@ describe('gnubok_create_supplier_invoice_from_inbox: execute', () => {
expect(params.vat_amount).toBe(120)
})
it('rejects apply_slp when the line resolves to a non-741x account (staging-time guard)', async () => {
// The executor (commitCreateSupplierInvoiceFromInbox) refuses the same
// combination at commit time; rejecting at staging means the agent learns
// immediately instead of a human approving a doomed operation.
const supabase = makeMock({
inbox: {
id: 'inbox-slp-1',
status: 'received',
extracted_data: baseExtracted, // line resolves to supplier default / 4000
matched_supplier_id: 'supplier-1',
created_supplier_invoice_id: null,
document_id: 'doc-slp-1',
},
})
const tool = tools.find((t) => t.name === 'gnubok_create_supplier_invoice_from_inbox')!
await expect(
tool.execute(
{
inbox_item_id: 'inbox-slp-1',
line_overrides: [{ line_number: 1, apply_slp: true }],
},
'company-1', 'user-1', supabase,
),
).rejects.toThrow(/7410-7419/)
})
it('stages apply_slp into the operation items when the line resolves to a 741x account', async () => {
const inserts: Array<Record<string, unknown>> = []
const supabase = makeMock({
inbox: {
id: 'inbox-slp-2',
status: 'received',
extracted_data: {
...baseExtracted,
lineItems: [
{ description: 'Tjänstepension', quantity: 1, unit_price: 10000, line_total: 10000, vat_rate: 0, vat_amount: 0 },
],
},
matched_supplier_id: 'supplier-1',
created_supplier_invoice_id: null,
document_id: 'doc-slp-2',
},
inserts,
})
const tool = tools.find((t) => t.name === 'gnubok_create_supplier_invoice_from_inbox')!
const result = (await tool.execute(
{
inbox_item_id: 'inbox-slp-2',
line_overrides: [{ line_number: 1, account_number: '7412', apply_slp: true }],
},
'company-1', 'user-1', supabase,
)) as { staged: boolean }
expect(result.staged).toBe(true)
const params = inserts[0].params as {
items: Array<{ account_number: string; apply_slp?: boolean }>
}
// The executor reads item.apply_slp === true to book the 7533/2514 pair:
// without this plumbing the flag was unreachable from MCP.
expect(params.items[0].account_number).toBe('7412')
expect(params.items[0].apply_slp).toBe(true)
})
it('invoice_date_override rescues an inbox item with no extracted invoiceDate', async () => {
const extractedNoDate = {
...baseExtracted,
+29 -3
View File
@@ -18,6 +18,8 @@ import { createLogger } from '@/lib/logger'
import { roundOre, sumOre } from '@/lib/money'
import type { SupabaseClient } from '@supabase/supabase-js'
import { buildMappingResultFromCategory } from '@/lib/bookkeeping/category-mapping'
import { isSlpPensionAccount } from '@/lib/bookkeeping/slp-lines'
import { getErrorEntry } from '@/lib/errors/structured-errors'
import { applySettlementAccount } from '@/lib/bookkeeping/mapping-engine'
import { resolveSettlementAccount } from '@/lib/bookkeeping/settlement-account'
import { buildTransactionEntryLines, createTransactionJournalEntry } from '@/lib/bookkeeping/transaction-entries'
@@ -9714,13 +9716,17 @@ export const tools: McpTool[] = [
due_date_override: { type: 'string', description: 'Override extracted due date (YYYY-MM-DD)' },
line_overrides: {
type: 'array',
description: 'Per-line overrides (1-based line_number): account_number wins over accountSuggestion and supplier default; dimensions tags that line.',
description: 'Per-line overrides (1-based line_number): account_number wins over accountSuggestion and supplier default; dimensions tags that line; apply_slp books särskild löneskatt on a 741x pension line.',
items: {
type: 'object',
additionalProperties: false,
properties: {
line_number: { type: 'number', description: '1-based index matching items_preview' },
account_number: { type: 'string', description: 'BAS account number for this line (e.g. "6420")' },
apply_slp: {
type: 'boolean',
description: 'Book särskild löneskatt (24.26%, 7533 D / 2514 K) for this line at commit. Only valid when the line resolves to a 741x pension-premium account (tjänstepension); any other account is rejected at staging.',
},
dimensions: {
type: 'object',
additionalProperties: { type: 'string' },
@@ -9927,10 +9933,13 @@ export const tools: McpTool[] = [
}
// Build lookups for per-line overrides keyed by 1-based line number.
const rawLineOverrides = (args.line_overrides as Array<{ line_number: number; account_number?: string; dimensions?: unknown }> | undefined) ?? []
const rawLineOverrides = (args.line_overrides as Array<{ line_number: number; account_number?: string; apply_slp?: boolean; dimensions?: unknown }> | undefined) ?? []
const lineOverrideMap = new Map(
rawLineOverrides.filter((o) => o.account_number).map((o) => [o.line_number, o.account_number as string]),
)
const lineSlpMap = new Map(
rawLineOverrides.filter((o) => o.apply_slp !== undefined).map((o) => [o.line_number, o.apply_slp === true]),
)
const lineDimensionsMap = new Map(
rawLineOverrides.map((o, i) => [o.line_number, parseDimensionsArg(o.dimensions, `line_overrides[${i}].dimensions`)]),
)
@@ -9969,6 +9978,22 @@ export const tools: McpTool[] = [
const vatAmount = rawVatAmount == null
? roundOre(lineTotal * vatRate)
: Number(rawVatAmount) || 0
const accountNumber = lineOverrideMap.get(lineNumber) ?? (li.accountSuggestion as string | null) ?? supplierDefaultExpenseAccount ?? '4000'
// Särskild löneskatt: same guard the create routes and the executor
// enforce (SI_CREATE_SLP_INVALID_ACCOUNT). Reject at staging time on
// the RESOLVED account so the agent learns immediately, instead of a
// human approving an operation the executor is guaranteed to refuse.
const applySlp = lineSlpMap.get(lineNumber) === true
if (applySlp && !isSlpPensionAccount(accountNumber)) {
const slpEntry = getErrorEntry('SI_CREATE_SLP_INVALID_ACCOUNT')
const slpErr = new Error(
`line_overrides[line_number=${lineNumber}]: apply_slp on account ${accountNumber}. `
+ `${slpEntry?.message_sv ?? 'Särskild löneskatt kan bara läggas till på rader med pensionskonto 7410-7419.'} / `
+ `${slpEntry?.message_en ?? 'Särskild löneskatt can only be added on lines booked to a pension account 7410-7419.'}`,
)
;(slpErr as Error & { code?: string }).code = 'SI_CREATE_SLP_INVALID_ACCOUNT'
throw slpErr
}
return {
line_number: lineNumber,
description: (li.description as string) ?? `Position ${lineNumber}`,
@@ -9976,9 +10001,10 @@ export const tools: McpTool[] = [
unit: (li.unit as string) ?? 'st',
unit_price: Number(li.unit_price ?? li.unitPrice ?? li.amount) || 0,
line_total: lineTotal,
account_number: lineOverrideMap.get(lineNumber) ?? (li.accountSuggestion as string | null) ?? supplierDefaultExpenseAccount ?? '4000',
account_number: accountNumber,
vat_rate: vatRate,
vat_amount: vatAmount,
...(applySlp ? { apply_slp: true } : {}),
...(dimensions && Object.keys(dimensions).length > 0 ? { dimensions } : {}),
}
})