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
@@ -61,6 +61,12 @@ const SI_RESPONSE_COLUMNS =
// decide whether a kontantmetoden credit note must reverse an entry the
// payment already posted. `status` alone is too weak, it misses a
// part-paid-but-booked original (rows predating the #1413 guard).
//
// Items keep `apply_slp` in the projection for the same reason as
// default_dimensions above: createSupplierCreditNoteEntry reads the flag off
// the ORIGINAL items to reverse the 7533/2514 SLP pair the registration
// booked. Dropping it would leave the pension cost and the 2514 liability
// standing forever after the credit.
const SI_FULL_COLUMNS = `
id, supplier_id, supplier_invoice_number, invoice_date, status,
currency, exchange_rate,
@@ -69,7 +75,7 @@ const SI_FULL_COLUMNS = `
registration_journal_entry_id, payment_journal_entry_id, paid_at, paid_amount,
is_credit_note, credited_invoice_id, arrival_number, default_dimensions,
supplier:suppliers(id, name, supplier_type),
items:supplier_invoice_items(id, sort_order, description, quantity, unit, unit_price, line_total, account_number, vat_code, vat_rate, vat_amount, reverse_charge_rate, dimensions)
items:supplier_invoice_items(id, sort_order, description, quantity, unit, unit_price, line_total, account_number, vat_code, vat_rate, vat_amount, reverse_charge_rate, apply_slp, dimensions)
`
const SupplierInvoiceCredited = z.object({
@@ -184,6 +190,7 @@ export const POST = withApiV1<{ params: Promise<{ companyId: string; id: string
vat_rate: number
vat_amount: number
reverse_charge_rate: number | null
apply_slp: boolean | null
dimensions: Record<string, string> | null
}>
} & Record<string, unknown>
@@ -332,6 +339,10 @@ export const POST = withApiV1<{ params: Promise<{ companyId: string; id: string
// Preserve the self-assessed RC rate so the credit note reverses fiktiv
// moms at the same rate the original was booked at.
reverse_charge_rate: item.reverse_charge_rate,
// Preserve the SLP flag for display parity with the web credit route;
// the journal reversal reads the ORIGINAL items, so the 7533/2514 swap
// is correct either way.
apply_slp: item.apply_slp ?? false,
// Same reasoning: the reversal must carry the exact per-item bag the
// original booked with (dimensions PR7).
dimensions: item.dimensions ?? {},
@@ -175,7 +175,7 @@ export const POST = withApiV1<{ params: Promise<{ companyId: string; id: string
subtotal, subtotal_sek, vat_amount, vat_amount_sek, total_sek, due_date, received_date,
is_credit_note, credited_invoice_id, payment_journal_entry_id, default_dimensions,
supplier:suppliers(id, name, supplier_type),
items:supplier_invoice_items(id, sort_order, description, quantity, unit, unit_price, line_total, account_number, vat_code, vat_rate, vat_amount, reverse_charge_rate, dimensions)
items:supplier_invoice_items(id, sort_order, description, quantity, unit, unit_price, line_total, account_number, vat_code, vat_rate, vat_amount, reverse_charge_rate, apply_slp, dimensions)
`)
.eq('company_id', ctx.companyId!)
.eq('id', invoiceId)
@@ -38,7 +38,7 @@ const SI_DETAIL_COLUMNS =
'id, supplier_id, arrival_number, supplier_invoice_number, invoice_date, due_date, received_date, delivery_date, status, currency, exchange_rate, exchange_rate_date, subtotal, subtotal_sek, vat_amount, vat_amount_sek, total, total_sek, vat_treatment, reverse_charge, payment_reference, paid_at, paid_amount, remaining_amount, is_credit_note, credited_invoice_id, registration_journal_entry_id, payment_journal_entry_id, transaction_id, document_id, notes, default_dimensions, reversed_at, created_at, updated_at'
const SI_ITEM_COLUMNS =
'id, sort_order, description, quantity, unit, unit_price, line_total, account_number, vat_code, vat_rate, vat_amount, reverse_charge_rate, dimensions'
'id, sort_order, description, quantity, unit, unit_price, line_total, account_number, vat_code, vat_rate, vat_amount, reverse_charge_rate, apply_slp, dimensions'
const SI_PAYMENT_COLUMNS =
'id, payment_date, amount, currency, exchange_rate, exchange_rate_difference, journal_entry_id, transaction_id, notes, created_at'
@@ -84,11 +84,19 @@ interface TableResp {
/** Payload handed to `.insert()`, recorded per table so writes can be asserted. */
type InsertRecord = { table: string; payload: Record<string, unknown> }
/** Column string handed to `.select()`, recorded per table. The Proxy mock
* returns fixture rows regardless of projection, so a column dropped from a
* SELECT never fails these tests by itself: capturing the projection is the
* only way to regression-test "this route must fetch column X". */
type SelectRecord = { table: string; columns: string }
function makeFlexibleSupabase(
byTable: Record<string, TableResp | TableResp[]>,
// Opt-in sink for insert payloads: the Proxy chain is otherwise write-only,
// and the route echoes back the fixture row rather than what it wrote.
insertSink?: InsertRecord[],
// Opt-in sink for select projections (see SelectRecord).
selectSink?: SelectRecord[],
) {
// Per-table queue: TableResp[] consumes one entry per await, then sticks
// on the last entry. Plain TableResp is treated as a constant.
@@ -116,6 +124,9 @@ function makeFlexibleSupabase(
) {
insertSink.push({ table, payload: args[0] as Record<string, unknown> })
}
if (selectSink && prop === 'select' && typeof args[0] === 'string') {
selectSink.push({ table, columns: args[0] })
}
return buildChain(table)
}
},
@@ -286,6 +297,28 @@ describe('GET /api/v1/companies/:companyId/supplier-invoices/:id', () => {
const body = await res.json()
expect(body.error.code).toBe('SI_NOT_FOUND')
})
it('?expand=items projects apply_slp so the flag is readable back', async () => {
const selects: Array<{ table: string; columns: string }> = []
mockServiceClient.mockReturnValue(
makeFlexibleSupabase(
{
company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null },
supplier_invoices: { data: { ...SAMPLE_SI, items: [] }, error: null },
},
undefined,
selects,
),
)
const res = await getSI(
makeRequest(`https://x.test/api/v1/companies/${COMPANY_ID}/supplier-invoices/${SI_ID}?expand=items`),
detailParams(COMPANY_ID, SI_ID),
)
expect(res.status).toBe(200)
const siSelect = selects.find((s) => s.table === 'supplier_invoices')
expect(siSelect).toBeDefined()
expect(siSelect!.columns).toMatch(/items:supplier_invoice_items\([^)]*apply_slp/)
})
})
describe('POST /api/v1/companies/:companyId/supplier-invoices', () => {
@@ -1443,6 +1476,69 @@ describe('POST /api/v1/companies/:companyId/supplier-invoices/:id/mark-paid', ()
expect(mockedPayment).not.toHaveBeenCalled()
})
it('kontantmetod: fetches apply_slp on the items and hands them to the cash entry builder', async () => {
// Regression: the items sub-select omitted apply_slp, so a kontantmetoden
// payment via v1 booked the cash entry WITHOUT the 7533/2514 SLP pair
// while the web mark-paid (select *) booked it. The projection is the
// bug surface: the Proxy mock returns fixture rows regardless, so the
// select string itself is asserted alongside the pass-through.
const slpItem = {
id: 'item-slp-1',
sort_order: 0,
description: 'Tjänstepension',
quantity: 1,
unit: 'st',
unit_price: 1250,
line_total: 1250,
account_number: '7412',
vat_code: null,
vat_rate: 0,
vat_amount: 0,
reverse_charge_rate: null,
apply_slp: true,
dimensions: {},
}
const cashSI = {
...SAMPLE_SI,
status: 'approved',
vat_amount: 0,
subtotal: 1250,
supplier: { id: SUPPLIER_ID, name: 'Avanza Pension', supplier_type: 'swedish_business' },
items: [slpItem],
}
const selects: Array<{ table: string; columns: string }> = []
mockServiceClient.mockReturnValue(
makeFlexibleSupabase(
{
company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null },
supplier_invoices: { data: cashSI, error: null },
company_settings: { data: { accounting_method: 'cash' }, error: null },
fiscal_periods: { data: { id: 'fp-1', is_closed: false, locked_at: null }, error: null },
supplier_invoice_payments: { data: null, error: null },
idempotency_keys: { data: null, error: null },
},
undefined,
selects,
),
)
const res = await markPaidSI(
makeRequest(`https://x.test/api/v1/companies/${COMPANY_ID}/supplier-invoices/${SI_ID}/mark-paid`, {
method: 'POST',
}),
detailParams(COMPANY_ID, SI_ID),
)
expect(res.status).toBe(200)
expect(mockedCash).toHaveBeenCalledTimes(1)
// The projection must carry the flag: without it the engine can never
// see apply_slp and silently skips the SLP pair.
const siSelect = selects.find((s) => s.table === 'supplier_invoices')
expect(siSelect).toBeDefined()
expect(siSelect!.columns).toMatch(/items:supplier_invoice_items\([^)]*apply_slp/)
// And the fetched items flow to createSupplierInvoiceCashEntry intact.
const passedItems = mockedCash.mock.calls[0]?.[4] as Array<{ apply_slp?: boolean }>
expect(passedItems[0]?.apply_slp).toBe(true)
})
it('rejects payment amount exceeding remaining_amount', async () => {
mockServiceClient.mockReturnValue(
makeFlexibleSupabase({
@@ -1558,6 +1654,104 @@ describe('POST /api/v1/companies/:companyId/supplier-invoices/:id/credit', () =>
expect(body.data.original_id).toBe(SI_ID)
})
it('reverses the SLP pair: fetches apply_slp, hands the flagged originals to the engine, copies the flag', async () => {
// Regression (blocker): SI_FULL_COLUMNS omitted apply_slp, so the credit
// path passed items without the flag to createSupplierCreditNoteEntry and
// the 7533/2514 pair booked at registration was never reversed: the
// pension cost and the 2514 liability stood forever, and the year-end
// netting then under-provisioned.
const slpSI = {
...registeredSI,
subtotal: 10000,
vat_amount: 0,
total: 10000,
remaining_amount: 10000,
items: [
{
sort_order: 0,
description: 'Tjänstepension',
quantity: 1,
unit: 'st',
unit_price: 10000,
line_total: 10000,
account_number: '7412',
vat_code: null,
vat_rate: 0,
vat_amount: 0,
reverse_charge_rate: null,
apply_slp: true,
dimensions: {},
},
],
}
const creditNoteRow = {
...SAMPLE_SI,
id: 'dddddddd-dddd-4ddd-8ddd-dddddddddddd',
arrival_number: 43,
supplier_invoice_number: 'KREDIT-2026-1234',
is_credit_note: true,
credited_invoice_id: SI_ID,
}
let siReadCount = 0
let insertedItems: Array<{ apply_slp?: boolean }> = []
const siSelects: string[] = []
mockServiceClient.mockReturnValue({
from: (table: string) => {
return new Proxy({}, {
get(_t, prop) {
if (prop === 'then') {
return (resolve: (v: unknown) => void) => {
if (table === 'company_members') {
resolve({ data: { company_id: COMPANY_ID, role: 'owner' }, error: null })
} else if (table === 'supplier_invoices') {
const n = siReadCount++
if (n === 0) resolve({ data: slpSI, error: null })
else if (n === 1) resolve({ data: creditNoteRow, error: null })
else resolve({ data: { id: SI_ID, status: 'credited' }, error: null })
} else if (table === 'company_settings') {
resolve({ data: { accounting_method: 'accrual' }, error: null })
} else if (table === 'fiscal_periods') {
resolve({ data: { id: 'fp-1', is_closed: false, locked_at: null }, error: null })
} else {
resolve({ data: null, error: null })
}
}
}
return (...args: unknown[]) => {
if (table === 'supplier_invoices' && prop === 'select' && typeof args[0] === 'string') {
siSelects.push(args[0])
}
if (table === 'supplier_invoice_items' && prop === 'insert') {
insertedItems = args[0] as Array<{ apply_slp?: boolean }>
}
return new Proxy({}, this!)
}
},
})
},
rpc: vi.fn(() => Promise.resolve({ data: 43, error: null })),
})
const res = await creditSI(
makeRequest(`https://x.test/api/v1/companies/${COMPANY_ID}/supplier-invoices/${SI_ID}/credit`, {
method: 'POST',
}),
detailParams(COMPANY_ID, SI_ID),
)
expect(res.status).toBe(200)
// The original fetch must project apply_slp: the Proxy returns the
// fixture regardless, so the select string is the regression surface.
expect(siSelects[0]).toMatch(/items:supplier_invoice_items\([^)]*apply_slp/)
// The ORIGINAL flagged items reach the engine so it can reverse the pair.
expect(mockedCredit).toHaveBeenCalledTimes(1)
const passedItems = mockedCredit.mock.calls[0]?.[4] as Array<{ apply_slp?: boolean }>
expect(passedItems[0]?.apply_slp).toBe(true)
// Parity with the web credit route: the flag is copied onto the new
// credit-note items for display.
expect(insertedItems[0]?.apply_slp).toBe(true)
})
it('returns 409 SI_CREDIT_ALREADY_CREDITED when status=credited', async () => {
mockServiceClient.mockReturnValue(
makeFlexibleSupabase({
@@ -39,6 +39,7 @@ import {
supplierInvoiceSekAmounts,
} from '@/lib/currency/supplier-invoice-rate'
import { createSupplierInvoiceRegistrationEntry } from '@/lib/bookkeeping/supplier-invoice-entries'
import { isSlpPensionAccount } from '@/lib/bookkeeping/slp-lines'
import { reverseEntry } from '@/lib/bookkeeping/engine'
import { isBookkeepingError } from '@/lib/bookkeeping/errors'
import { eventBus } from '@/lib/events'
@@ -281,7 +282,7 @@ const SI_RESPONSE_COLUMNS =
'id, supplier_id, arrival_number, supplier_invoice_number, invoice_date, due_date, received_date, delivery_date, status, currency, exchange_rate, subtotal, subtotal_sek, vat_amount, vat_amount_sek, total, total_sek, vat_treatment, reverse_charge, payment_reference, paid_amount, remaining_amount, is_credit_note, credited_invoice_id, registration_journal_entry_id, payment_journal_entry_id, notes, default_dimensions, created_at, updated_at'
const SI_ITEMS_RESPONSE_COLUMNS =
'id, sort_order, description, quantity, unit, unit_price, line_total, account_number, vat_code, vat_rate, vat_amount, reverse_charge_rate, dimensions'
'id, sort_order, description, quantity, unit, unit_price, line_total, account_number, vat_code, vat_rate, vat_amount, reverse_charge_rate, apply_slp, dimensions'
const SupplierInvoiceCreated = z.object({
id: z.string().uuid(),
@@ -321,6 +322,7 @@ registerEndpoint({
'Foreign currency: omit exchange_rate and the server fetches Riksbanken\'s rate for invoice_date (ML 8 kap 21-23 §). If no rate can be resolved the create is refused with 400 SI_FX_RATE_MISSING rather than stored unconverted: pass exchange_rate explicitly to proceed. A SEK invoice needs no rate and gets total_sek = total.',
'exchange_rate is SEK per 1 unit of the invoice currency and must satisfy 0 < rate < 100000, the same bounds the supplier_invoices CHECK enforces. Out-of-range values return 400 VALIDATION_ERROR; passing an invoice total where a rate belongs is the usual cause.',
'Project/cost-center tagging: pass default_dimensions ({"6":"P001"} = project, {"1":"KS01"} = kostnadsställe) for the whole invoice and/or items[].dimensions per line (per-line wins per key). The registration JE lines are tagged accordingly. When the company has the dimension registry enabled, unknown or archived codes are rejected with 400 DIMENSION_VALIDATION_FAILED — list valid codes via GET /dimensions.',
'Tjänstepension invoices (Avanza etc.): set items[].apply_slp=true on the 741x premium line and the registration JE also books särskild löneskatt (debit 7533 / credit 2514 at 24.26% of the line amount) beyond the payable: 2440 stays at the invoice total. apply_slp on a non-741x account returns 400 SI_CREATE_SLP_INVALID_ACCOUNT.',
],
example: {
request: {
@@ -367,6 +369,7 @@ interface ComputedItem {
vat_rate: number
vat_amount: number
reverse_charge_rate: number | null
apply_slp: boolean
dimensions: Record<string, string>
}
@@ -412,6 +415,10 @@ function computeItemsAndTotals(input: z.infer<typeof CreateSupplierInvoiceSchema
// line vat_rate is 0 (validated below); the engine self-assesses at this
// rate, defaulting to 25% huvudregeln when null.
reverse_charge_rate: item.reverse_charge_rate ?? null,
// Särskild löneskatt (SLP): booking injects the self-balancing
// 7533/2514 pair for this line. Validated in the POST handler
// (741x accounts only, never together with periodisering).
apply_slp: item.apply_slp === true,
// Dimensions PR7: per-item bag, merged over the invoice's
// default_dimensions on the expense line at booking.
dimensions: item.dimensions ?? {},
@@ -463,6 +470,26 @@ export const POST = withApiV1<{ params: Promise<{ companyId: string }> }>(
}
const body = parsed.data
// Särskild löneskatt (SLP): the 7533/2514 pair is only lawful on 741x
// pension premiums and cannot be combined with periodisering on the same
// item. Same guards as POST /api/supplier-invoices.
if (body.items.some((it) => it.apply_slp && !isSlpPensionAccount(it.account_number))) {
return v1ErrorResponseFromCode('SI_CREATE_SLP_INVALID_ACCOUNT', ctx.log, {
requestId: ctx.requestId,
})
}
if (
body.items.some(
(it) =>
it.apply_slp &&
(it.accrual_period_start || it.accrual_period_end || it.accrual_balance_account),
)
) {
return v1ErrorResponseFromCode('SI_CREATE_SLP_ACCRUAL', ctx.log, {
requestId: ctx.requestId,
})
}
// Supplier lookup. Scoped to company; deny soft-archived.
const { data: supplier, error: supplierErr } = await ctx.supabase
.from('suppliers')