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
@@ -92,6 +92,9 @@ export const POST = withRouteContext(
// Preserve the self-assessed RC rate so the credit-note verifikat
// 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; the journal reversal reads
// the ORIGINAL items, so the 7533/2514 swap is correct either way.
apply_slp: item.apply_slp ?? false,
// Dims copied for display parity; the journal reversal reads the
// ORIGINAL items below (dimensions PR7).
dimensions: item.dimensions ?? {},
@@ -7,7 +7,7 @@ import {
makeSupplier,
} from '@/tests/helpers'
const { supabase: mockSupabase, enqueue, reset } = createQueuedMockSupabase()
const { supabase: mockSupabase, enqueue, reset, findCall } = createQueuedMockSupabase()
vi.mock('@/lib/supabase/server', () => ({
createClient: () => Promise.resolve(mockSupabase),
}))
@@ -1142,3 +1142,123 @@ describe('POST /api/supplier-invoices: exchange rate + SEK amounts', () => {
expect(supplierInvoiceInsert()!.exchange_rate).toBe(99999.99)
})
})
// ── Särskild löneskatt (SLP, apply_slp) ─────────────────────────────────────
describe('POST /api/supplier-invoices: särskild löneskatt (apply_slp)', () => {
const mockUser = { id: 'user-1', email: 'test@test.se' }
function slpBody(items: Record<string, unknown>[]) {
return {
supplier_id: VALID_UUID,
supplier_invoice_number: 'LF-SLP',
invoice_date: '2024-06-01',
due_date: '2024-07-01',
items,
}
}
beforeEach(() => {
vi.clearAllMocks()
reset()
eventBus.clear()
mockSupabase.auth.getUser.mockResolvedValue({ data: { user: mockUser } })
})
it('rejects apply_slp on a non-741x account with SI_CREATE_SLP_INVALID_ACCOUNT', async () => {
const response = await POST(
createMockRequest('/api/supplier-invoices', {
method: 'POST',
body: slpBody([
{ description: 'Konsult', amount: 10000, account_number: '6200', vat_rate: 0.25, apply_slp: true },
]),
}),
)
const { status, body } = await parseJsonResponse<{ error: { code: string } }>(response)
expect(status).toBe(400)
expect(body.error.code).toBe('SI_CREATE_SLP_INVALID_ACCOUNT')
expect(mockCreateSupplierInvoiceRegistrationEntry).not.toHaveBeenCalled()
})
it('rejects apply_slp combined with periodisering on the same item', async () => {
const response = await POST(
createMockRequest('/api/supplier-invoices', {
method: 'POST',
body: slpBody([
{
description: 'Tjänstepension',
amount: 10000,
account_number: '7412',
vat_rate: 0,
apply_slp: true,
accrual_period_start: '2024-06-01',
accrual_period_end: '2024-12-31',
},
]),
}),
)
const { status, body } = await parseJsonResponse<{ error: { code: string } }>(response)
expect(status).toBe(400)
expect(body.error.code).toBe('SI_CREATE_SLP_ACCRUAL')
expect(mockCreateSupplierInvoiceRegistrationEntry).not.toHaveBeenCalled()
})
it('happy path: apply_slp on a 7412 line is stored on the item and reaches the generator', async () => {
enqueue({ data: makeSupplier({ id: VALID_UUID }), error: null }) // supplier lookup
enqueue({ data: 9 }) // get_next_arrival_number
enqueue({ data: makeSupplierInvoice({ id: 'si-slp' }), error: null }) // insert invoice
enqueue({ data: [], error: null }) // insert items
enqueue({ data: { accounting_method: 'accrual' }, error: null }) // settings
mockCreateSupplierInvoiceRegistrationEntry.mockResolvedValue({ id: 'je-slp' })
enqueue({ data: null, error: null }) // update registration_journal_entry_id
const response = await POST(
createMockRequest('/api/supplier-invoices', {
method: 'POST',
body: slpBody([
{ description: 'Avanza tjänstepension', amount: 10000, account_number: '7412', vat_rate: 0, apply_slp: true },
]),
}),
)
const { status } = await parseJsonResponse(response)
expect(status).toBe(200)
// The DB insert carries the flag...
const itemsInsert = findCall('supplier_invoice_items', 'insert')
expect(itemsInsert).toBeDefined()
const rows = itemsInsert![0] as Array<Record<string, unknown>>
expect(rows[0].apply_slp).toBe(true)
expect(rows[0].account_number).toBe('7412')
// ...and the same items array reaches the registration generator, which
// injects the 7533/2514 pair from it.
const generatorItems = mockCreateSupplierInvoiceRegistrationEntry.mock
.calls[0][4] as Array<Record<string, unknown>>
expect(generatorItems[0].apply_slp).toBe(true)
})
it('defaults apply_slp to false when omitted', async () => {
enqueue({ data: makeSupplier({ id: VALID_UUID }), error: null })
enqueue({ data: 10 })
enqueue({ data: makeSupplierInvoice({ id: 'si-noslp' }), error: null })
enqueue({ data: [], error: null })
enqueue({ data: { accounting_method: 'cash' }, error: null })
const response = await POST(
createMockRequest('/api/supplier-invoices', {
method: 'POST',
body: slpBody([
{ description: 'Pensionspremie utan SLP-flagga', amount: 5000, account_number: '7412', vat_rate: 0 },
]),
}),
)
const { status } = await parseJsonResponse(response)
expect(status).toBe(200)
const itemsInsert = findCall('supplier_invoice_items', 'insert')
const rows = itemsInsert![0] as Array<Record<string, unknown>>
expect(rows[0].apply_slp).toBe(false)
})
})
+21
View File
@@ -6,6 +6,7 @@ import {
} 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 { isBookkeepingError } from '@/lib/bookkeeping/errors'
import { booksInvoicesOnIssue } from '@/lib/bookkeeping/booking-mode'
import { ensureInitialized } from '@/lib/init'
@@ -122,6 +123,23 @@ export const POST = withRouteContext(
})
}
// Särskild löneskatt (SLP): the 7533/2514 pair is only lawful on pension
// premiums, so the flag is rejected on any non-741x account, and rejected
// together with periodisering on the same row (the pair is computed on
// the full line amount at registration and cannot be deferred).
if (body.items.some((item) => item.apply_slp && !isSlpPensionAccount(item.account_number))) {
return errorResponseFromCode('SI_CREATE_SLP_INVALID_ACCOUNT', log, { requestId })
}
if (
body.items.some(
(item) =>
item.apply_slp &&
(item.accrual_period_start || item.accrual_period_end || item.accrual_balance_account),
)
) {
return errorResponseFromCode('SI_CREATE_SLP_ACCRUAL', log, { requestId })
}
const hasAccrualItems = body.items.some(
(item) => item.accrual_period_start && item.accrual_period_end,
)
@@ -256,6 +274,9 @@ export const POST = withRouteContext(
// Dimensions PR7: per-item bag, merged over default_dimensions on the
// expense line at booking (supplier-invoice-entries.ts).
dimensions: item.dimensions ?? {},
// Särskild löneskatt (SLP): booking injects the self-balancing
// 7533/2514 pair for this line. Guarded above (741x only, no accrual).
apply_slp: item.apply_slp === true,
}
})
@@ -112,6 +112,59 @@ describe('GET /api/transactions/[id]/match-supplier-invoice/preview: settlement
expect(body.lines.find((l) => l.account_number === '1930')?.credit_amount).toBe(750)
})
it('kontantmetod: the cash preview includes the SLP pair the POST will book for a flagged 741x line', async () => {
// Regression: the cash branch previewed expense + VAT + bank only, while
// createSupplierInvoiceCashEntry also books 7533 D / 2514 K for items
// flagged apply_slp on a 741x pension account. The user approved four
// lines and the POST committed six.
enqueue({
data: {
id: TX_UUID,
date: '2026-02-01',
amount: -10000,
currency: 'SEK',
amount_sek: null,
cash_account_id: null,
},
error: null,
})
enqueue({
data: {
id: SI_UUID,
currency: 'SEK',
exchange_rate: null,
total: 10000,
remaining_amount: 10000,
paid_amount: 0,
registration_journal_entry_id: null,
items: [
{
description: 'Tjänstepension',
line_total: 10000,
vat_amount: 0,
account_number: '7412',
apply_slp: true,
},
],
},
error: null,
})
enqueue({ data: { accounting_method: 'cash' }, error: null })
const res = await GET(makeReq(), createMockRouteParams({ id: TX_UUID }))
const { body } = await parseJsonResponse<{
entry_type: string
lines: Array<{ account_number: string; debit_amount: number; credit_amount: number }>
}>(res)
expect(body.entry_type).toBe('cash')
// 10 000 × 0.2426 = 2 426: mirrors generateSlpLines in the engine.
expect(body.lines.find((l) => l.account_number === '7533')?.debit_amount).toBe(2426)
expect(body.lines.find((l) => l.account_number === '2514')?.credit_amount).toBe(2426)
// The pair nets to zero: the bank credit stays at the invoice total.
expect(body.lines.find((l) => l.account_number === '1930')?.credit_amount).toBe(10000)
})
it('previews a credit to the linked cash account when it is not the primary 1930', async () => {
enqueue({
data: {
@@ -13,6 +13,7 @@ import { withRouteContext } from '@/lib/api/with-route-context'
import { errorResponseFromCode } from '@/lib/errors/get-structured-error'
import { cashPartialBlockReason } from '@/lib/bookkeeping/booking-mode'
import { resolveSekAmount } from '@/lib/bookkeeping/currency-utils'
import { generateSlpLines, isSlpPensionAccount } from '@/lib/bookkeeping/slp-lines'
import { buildSupplierPaymentClearingLines } from '@/lib/bookkeeping/supplier-payment-lines'
import { resolveSettlementAccount } from '@/lib/bookkeeping/settlement-account'
import { ORE_TOLERANCE } from '@/lib/money'
@@ -241,6 +242,28 @@ export const GET = withRouteContext(
})
}
// Mirror createSupplierInvoiceCashEntry's SLP pair: items flagged
// apply_slp on a 741x pension account book 7533 D / 2514 K at the same
// rate the expense lines above use. The pair nets to zero, so the bank
// credit below is untouched; without it the dialog shows fewer lines
// than the POST actually books.
let slpBase = 0
for (const it of items) {
if (it.apply_slp !== true) continue
if (!isSlpPensionAccount(it.account_number)) continue
slpBase += resolveSekAmount(it.line_total, null, si.currency, cashRate)
}
if (slpBase > 0) {
for (const l of generateSlpLines(slpBase)) {
lines.push({
account_number: l.account_number,
debit_amount: l.debit_amount,
credit_amount: l.credit_amount,
description: l.line_description ?? '',
})
}
}
lines.push({
account_number: paymentAccount,
debit_amount: 0,
@@ -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')