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:
co-authored by
Claude Fable 5
Jakob Wennberg
parent
3a7688c163
commit
7cf0e34434
@@ -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,
|
||||
|
||||
@@ -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 } : {}),
|
||||
}
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user