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
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user