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
@@ -0,0 +1,67 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { SLP_RATE, generateSlpLines, isSlpPensionAccount } from '../slp-lines'
|
||||
|
||||
describe('SLP_RATE', () => {
|
||||
it('is the statutory 24.26 % (SLF 1991:687)', () => {
|
||||
expect(SLP_RATE).toBe(0.2426)
|
||||
})
|
||||
})
|
||||
|
||||
describe('isSlpPensionAccount', () => {
|
||||
it('accepts the whole 7410-7419 range', () => {
|
||||
for (let i = 0; i <= 9; i++) {
|
||||
expect(isSlpPensionAccount(`741${i}`)).toBe(true)
|
||||
}
|
||||
})
|
||||
|
||||
it('rejects everything outside the range', () => {
|
||||
for (const account of ['7400', '7420', '7533', '2514', '6200', '741', '74100', '']) {
|
||||
expect(isSlpPensionAccount(account)).toBe(false)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('generateSlpLines', () => {
|
||||
it('builds the 7533 D / 2514 K pair at 24.26 % (Avanza case: 10 000 kr premie)', () => {
|
||||
const lines = generateSlpLines(10000)
|
||||
expect(lines).toHaveLength(2)
|
||||
|
||||
const [debit, credit] = lines
|
||||
expect(debit.account_number).toBe('7533')
|
||||
expect(debit.debit_amount).toBe(2426)
|
||||
expect(debit.credit_amount).toBe(0)
|
||||
|
||||
expect(credit.account_number).toBe('2514')
|
||||
expect(credit.credit_amount).toBe(2426)
|
||||
expect(credit.debit_amount).toBe(0)
|
||||
|
||||
expect(debit.line_description).toBe('Särskild löneskatt på pensionskostnader (24,26 %)')
|
||||
})
|
||||
|
||||
it('nets to zero (never moves the payable)', () => {
|
||||
const lines = generateSlpLines(1234.56)
|
||||
const debits = lines.reduce((s, l) => s + l.debit_amount, 0)
|
||||
const credits = lines.reduce((s, l) => s + l.credit_amount, 0)
|
||||
expect(debits).toBe(credits)
|
||||
expect(debits).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it('rounds to öre with Math.round semantics', () => {
|
||||
// 1000.01 × 0.2426 = 242.602426 → 242.60
|
||||
const lines = generateSlpLines(1000.01)
|
||||
expect(lines[0].debit_amount).toBe(242.6)
|
||||
// 103 × 0.2426 = 24.9878 → 24.99
|
||||
expect(generateSlpLines(103)[0].debit_amount).toBe(24.99)
|
||||
})
|
||||
|
||||
it('returns [] for zero and negative bases', () => {
|
||||
expect(generateSlpLines(0)).toEqual([])
|
||||
expect(generateSlpLines(-5000)).toEqual([])
|
||||
})
|
||||
|
||||
it('returns [] when rounding produces a zero amount', () => {
|
||||
// 0.01 × 0.2426 = 0.002426 → rounds to 0.00: no zero-amount lines, the
|
||||
// engine requires every posted line amount > 0 on one side.
|
||||
expect(generateSlpLines(0.01)).toEqual([])
|
||||
})
|
||||
})
|
||||
@@ -2195,3 +2195,244 @@ describe('dimensions propagation (PR7): createSupplierCreditNoteEntry', () => {
|
||||
assertBalanced(input)
|
||||
})
|
||||
})
|
||||
|
||||
// ============================================================
|
||||
// Särskild löneskatt på pensionskostnader (SLP) pair injection
|
||||
// ============================================================
|
||||
|
||||
describe('SLP pair injection (apply_slp)', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockedFindFiscalPeriod.mockResolvedValue('period-1')
|
||||
})
|
||||
|
||||
// The customer case verbatim: Avanza tjänstepension, 10 000 kr premie.
|
||||
// 2440 K 10 000 / 7412 D 10 000 / 7533 D 2 426 / 2514 K 2 426.
|
||||
it('registration books the 4-line Avanza case: 7533/2514 nets to zero, payable untouched', async () => {
|
||||
const invoice = makeSupplierInvoice({
|
||||
subtotal: 10000,
|
||||
vat_amount: 0,
|
||||
total: 10000,
|
||||
})
|
||||
const items = [
|
||||
makeItem({ line_total: 10000, account_number: '7412', vat_rate: 0, apply_slp: true }),
|
||||
]
|
||||
|
||||
await createSupplierInvoiceRegistrationEntry(
|
||||
null as never, 'company-1', 'user-1', invoice, items, 'swedish_business'
|
||||
)
|
||||
|
||||
const input = mockedCreateEntry.mock.calls[0][3]
|
||||
expect(input.lines).toHaveLength(4)
|
||||
|
||||
expect(findByAccount(input.lines, '7412')[0].debit_amount).toBe(10000)
|
||||
expect(findByAccount(input.lines, '7533')[0].debit_amount).toBe(2426)
|
||||
expect(findByAccount(input.lines, '2514')[0].credit_amount).toBe(2426)
|
||||
// The pair nets to zero: 2440 stays at exactly the invoice total.
|
||||
expect(findByAccount(input.lines, '2440')[0].credit_amount).toBe(10000)
|
||||
|
||||
assertBalanced(input)
|
||||
})
|
||||
|
||||
it('multiple flagged 741x rows aggregate into ONE 7533/2514 pair', async () => {
|
||||
const invoice = makeSupplierInvoice({ subtotal: 15000, vat_amount: 0, total: 15000 })
|
||||
const items = [
|
||||
makeItem({ id: 'i1', line_total: 10000, account_number: '7412', vat_rate: 0, apply_slp: true }),
|
||||
makeItem({ id: 'i2', line_total: 5000, account_number: '7410', vat_rate: 0, apply_slp: true }),
|
||||
]
|
||||
|
||||
await createSupplierInvoiceRegistrationEntry(
|
||||
null as never, 'company-1', 'user-1', invoice, items, 'swedish_business'
|
||||
)
|
||||
|
||||
const input = mockedCreateEntry.mock.calls[0][3]
|
||||
const slpDebit = findByAccount(input.lines, '7533')
|
||||
expect(slpDebit).toHaveLength(1)
|
||||
// 15 000 × 0.2426 = 3 639
|
||||
expect(slpDebit[0].debit_amount).toBe(3639)
|
||||
expect(findByAccount(input.lines, '2514')[0].credit_amount).toBe(3639)
|
||||
expect(findByAccount(input.lines, '2440')[0].credit_amount).toBe(15000)
|
||||
assertBalanced(input)
|
||||
})
|
||||
|
||||
it('rounds the pair to öre (base 1 000,01 → 242,60)', async () => {
|
||||
const invoice = makeSupplierInvoice({ subtotal: 1000.01, vat_amount: 0, total: 1000.01 })
|
||||
const items = [
|
||||
makeItem({ line_total: 1000.01, account_number: '7412', vat_rate: 0, apply_slp: true }),
|
||||
]
|
||||
|
||||
await createSupplierInvoiceRegistrationEntry(
|
||||
null as never, 'company-1', 'user-1', invoice, items, 'swedish_business'
|
||||
)
|
||||
|
||||
const input = mockedCreateEntry.mock.calls[0][3]
|
||||
expect(findByAccount(input.lines, '7533')[0].debit_amount).toBe(242.6)
|
||||
expect(findByAccount(input.lines, '2514')[0].credit_amount).toBe(242.6)
|
||||
expect(findByAccount(input.lines, '2440')[0].credit_amount).toBe(1000.01)
|
||||
assertBalanced(input)
|
||||
})
|
||||
|
||||
it('ignores apply_slp on a non-741x account (defense in depth behind the route guard)', async () => {
|
||||
const invoice = makeSupplierInvoice({ subtotal: 8000, vat_amount: 2000, total: 10000 })
|
||||
const items = [
|
||||
makeItem({ line_total: 8000, account_number: '6200', vat_rate: 0.25, apply_slp: true }),
|
||||
]
|
||||
|
||||
await createSupplierInvoiceRegistrationEntry(
|
||||
null as never, 'company-1', 'user-1', invoice, items, 'swedish_business'
|
||||
)
|
||||
|
||||
const input = mockedCreateEntry.mock.calls[0][3]
|
||||
expect(findByAccount(input.lines, '7533')).toHaveLength(0)
|
||||
expect(findByAccount(input.lines, '2514')).toHaveLength(0)
|
||||
assertBalanced(input)
|
||||
})
|
||||
|
||||
it('unflagged 741x rows book without the pair (opt-in, never automatic)', async () => {
|
||||
const invoice = makeSupplierInvoice({ subtotal: 10000, vat_amount: 0, total: 10000 })
|
||||
const items = [
|
||||
makeItem({ line_total: 10000, account_number: '7412', vat_rate: 0 }),
|
||||
]
|
||||
|
||||
await createSupplierInvoiceRegistrationEntry(
|
||||
null as never, 'company-1', 'user-1', invoice, items, 'swedish_business'
|
||||
)
|
||||
|
||||
const input = mockedCreateEntry.mock.calls[0][3]
|
||||
expect(findByAccount(input.lines, '7533')).toHaveLength(0)
|
||||
expect(findByAccount(input.lines, '2514')).toHaveLength(0)
|
||||
expect(findByAccount(input.lines, '2440')[0].credit_amount).toBe(10000)
|
||||
assertBalanced(input)
|
||||
})
|
||||
|
||||
it('SLP lines carry the invoice default dimensions like the RC pairs do', async () => {
|
||||
const invoice = makeSupplierInvoice({
|
||||
subtotal: 10000,
|
||||
vat_amount: 0,
|
||||
total: 10000,
|
||||
default_dimensions: { '1': 'KS01' },
|
||||
})
|
||||
const items = [
|
||||
makeItem({ line_total: 10000, account_number: '7412', vat_rate: 0, apply_slp: true }),
|
||||
]
|
||||
|
||||
await createSupplierInvoiceRegistrationEntry(
|
||||
null as never, 'company-1', 'user-1', invoice, items, 'swedish_business'
|
||||
)
|
||||
|
||||
const input = mockedCreateEntry.mock.calls[0][3]
|
||||
expect(findByAccount(input.lines, '7533')[0].dimensions).toEqual({ '1': 'KS01' })
|
||||
expect(findByAccount(input.lines, '2514')[0].dimensions).toEqual({ '1': 'KS01' })
|
||||
})
|
||||
|
||||
it('kontantmetoden: pair injected and the bank credit stays at the invoice total', async () => {
|
||||
const invoice = makeSupplierInvoice({ subtotal: 10000, vat_amount: 0, total: 10000 })
|
||||
const items = [
|
||||
makeItem({ line_total: 10000, account_number: '7412', vat_rate: 0, apply_slp: true }),
|
||||
]
|
||||
|
||||
await createSupplierInvoiceCashEntry(
|
||||
null as never, 'company-1', 'user-1', invoice, items, '2024-07-01', 'swedish_business'
|
||||
)
|
||||
|
||||
const input = mockedCreateEntry.mock.calls[0][3]
|
||||
expect(findByAccount(input.lines, '7533')[0].debit_amount).toBe(2426)
|
||||
expect(findByAccount(input.lines, '2514')[0].credit_amount).toBe(2426)
|
||||
// Payment guarantee: the bank movement equals the invoice total exactly.
|
||||
expect(findByAccount(input.lines, '1930')[0].credit_amount).toBe(10000)
|
||||
assertBalanced(input)
|
||||
})
|
||||
|
||||
it('privately paid (eget utlägg): pair injected and the owner account stays at the invoice total', async () => {
|
||||
const invoice = makeSupplierInvoice({ subtotal: 10000, vat_amount: 0, total: 10000 })
|
||||
const items = [
|
||||
makeItem({ line_total: 10000, account_number: '7412', vat_rate: 0, apply_slp: true }),
|
||||
]
|
||||
|
||||
await createSupplierInvoicePrivatelyPaidEntry(
|
||||
null as never, 'company-1', 'user-1', invoice, items, 'aktiebolag'
|
||||
)
|
||||
|
||||
const input = mockedCreateEntry.mock.calls[0][3]
|
||||
expect(findByAccount(input.lines, '7533')[0].debit_amount).toBe(2426)
|
||||
expect(findByAccount(input.lines, '2514')[0].credit_amount).toBe(2426)
|
||||
// The SLP pair must not inflate what the owner is owed.
|
||||
expect(findByAccount(input.lines, '2893')[0].credit_amount).toBe(10000)
|
||||
assertBalanced(input)
|
||||
})
|
||||
|
||||
it('credit note reverses the pair (7533 K / 2514 D) and keeps 2440 at the invoice total', async () => {
|
||||
const creditNote = makeSupplierInvoice({
|
||||
is_credit_note: true,
|
||||
subtotal: -10000,
|
||||
vat_amount: 0,
|
||||
total: -10000,
|
||||
})
|
||||
// The caller passes the ORIGINAL invoice's items (positive line_total).
|
||||
const items = [
|
||||
makeItem({ line_total: 10000, account_number: '7412', vat_rate: 0, apply_slp: true }),
|
||||
]
|
||||
|
||||
await createSupplierCreditNoteEntry(
|
||||
null as never, 'company-1', 'user-1', creditNote, items, 'swedish_business'
|
||||
)
|
||||
|
||||
const input = mockedCreateEntry.mock.calls[0][3]
|
||||
// Swapped sides vs registration.
|
||||
expect(findByAccount(input.lines, '7533')[0].credit_amount).toBe(2426)
|
||||
expect(findByAccount(input.lines, '7533')[0].debit_amount).toBe(0)
|
||||
expect(findByAccount(input.lines, '2514')[0].debit_amount).toBe(2426)
|
||||
expect(findByAccount(input.lines, '2514')[0].credit_amount).toBe(0)
|
||||
// Pair nets to zero: 2440 debit clears exactly the original payable.
|
||||
expect(findByAccount(input.lines, '2440')[0].debit_amount).toBe(10000)
|
||||
assertBalanced(input)
|
||||
})
|
||||
|
||||
it('mixed-sign flagged original: credit note reverses SLP on the signed sum, not per-item abs', async () => {
|
||||
// Registration computed its SLP base as the SIGNED sum of flagged
|
||||
// line_totals: +10000 premium and -2000 rebate booked SLP on 8000
|
||||
// (1 940,80). Per-item abs on the credit note would reverse SLP on
|
||||
// 12000 (2 911,20), striking more 7533/2514 than was ever posted.
|
||||
const creditNote = makeSupplierInvoice({
|
||||
is_credit_note: true,
|
||||
subtotal: -8000,
|
||||
vat_amount: 0,
|
||||
total: -8000,
|
||||
})
|
||||
const items = [
|
||||
makeItem({ id: 'i1', line_total: 10000, account_number: '7412', vat_rate: 0, apply_slp: true }),
|
||||
makeItem({ id: 'i2', line_total: -2000, account_number: '7412', vat_rate: 0, apply_slp: true }),
|
||||
]
|
||||
|
||||
await createSupplierCreditNoteEntry(
|
||||
null as never, 'company-1', 'user-1', creditNote, items, 'swedish_business'
|
||||
)
|
||||
|
||||
const input = mockedCreateEntry.mock.calls[0][3]
|
||||
// 8 000 × 0.2426 = 1 940,80: exactly what registration booked.
|
||||
expect(findByAccount(input.lines, '7533')[0].credit_amount).toBe(1940.8)
|
||||
expect(findByAccount(input.lines, '2514')[0].debit_amount).toBe(1940.8)
|
||||
assertBalanced(input)
|
||||
})
|
||||
|
||||
it('foreign-currency invoice books the pair in SEK at the invoice rate', async () => {
|
||||
const invoice = makeSupplierInvoice({
|
||||
subtotal: 1000, vat_amount: 0, total: 1000,
|
||||
currency: 'EUR', exchange_rate: 11.50,
|
||||
})
|
||||
const items = [
|
||||
makeItem({ line_total: 1000, account_number: '7412', vat_rate: 0, apply_slp: true }),
|
||||
]
|
||||
|
||||
await createSupplierInvoiceRegistrationEntry(
|
||||
null as never, 'company-1', 'user-1', invoice, items, 'swedish_business'
|
||||
)
|
||||
|
||||
const input = mockedCreateEntry.mock.calls[0][3]
|
||||
// 11 500 SEK × 0.2426 = 2 789.90
|
||||
expect(findByAccount(input.lines, '7533')[0].debit_amount).toBe(2789.9)
|
||||
expect(findByAccount(input.lines, '2514')[0].credit_amount).toBe(2789.9)
|
||||
expect(findByAccount(input.lines, '2440')[0].credit_amount).toBe(11500)
|
||||
assertBalanced(input)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
// Särskild löneskatt på pensionskostnader (SLP), SLF 1991:687.
|
||||
//
|
||||
// Leaf module (only lib/money + types) so the booking engine, the API routes,
|
||||
// the client-side supplier-invoice form/preview, and the bokslut calculator
|
||||
// can all share the rate and the line pair without dragging server-only
|
||||
// dependencies into client bundles.
|
||||
|
||||
import { roundOre } from '@/lib/money'
|
||||
import type { CreateJournalEntryLineInput } from '@/types'
|
||||
|
||||
/** Särskild löneskatt på pensionskostnader. 24.26 % per SLF 1991:687. */
|
||||
export const SLP_RATE = 0.2426
|
||||
|
||||
/**
|
||||
* Accounts whose costs carry SLP when flagged on a supplier invoice line:
|
||||
* BAS 7410-7419 (pensionsförsäkringspremier m.m.). The same range the
|
||||
* bokslut calculator reads as the year-end SLP base
|
||||
* (lib/bokslut/tax-provision/sarskild-loneskatt-calculator.ts).
|
||||
*/
|
||||
export function isSlpPensionAccount(accountNumber: string): boolean {
|
||||
return /^741\d$/.test(accountNumber)
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the self-balancing SLP pair for a pension-premium base (SEK):
|
||||
*
|
||||
* Debit 7533 Särskild löneskatt för pensionskostnader [base x 24.26 %]
|
||||
* Credit 2514 Beräknad särskild löneskatt pensionskostnader [same amount]
|
||||
*
|
||||
* The pair nets to zero, so injecting it into a supplier-invoice verifikat
|
||||
* never moves the payable (2440): the same mechanism reverse-charge fiktiv
|
||||
* moms uses to book beyond the payable. Returns [] for a non-positive base
|
||||
* or when rounding produces a zero amount.
|
||||
*/
|
||||
export function generateSlpLines(baseSek: number): CreateJournalEntryLineInput[] {
|
||||
if (baseSek <= 0) return []
|
||||
const amount = roundOre(baseSek * SLP_RATE)
|
||||
if (amount <= 0) return []
|
||||
const desc = 'Särskild löneskatt på pensionskostnader (24,26 %)'
|
||||
return [
|
||||
{
|
||||
account_number: '7533',
|
||||
debit_amount: amount,
|
||||
credit_amount: 0,
|
||||
line_description: desc,
|
||||
},
|
||||
{
|
||||
account_number: '2514',
|
||||
debit_amount: 0,
|
||||
credit_amount: amount,
|
||||
line_description: desc,
|
||||
},
|
||||
]
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
isReverseChargeBasisAccount,
|
||||
resolveReverseChargeRate,
|
||||
} from './vat-entries'
|
||||
import { generateSlpLines, isSlpPensionAccount } from './slp-lines'
|
||||
import {
|
||||
coerceDimensionsBag,
|
||||
dimensionsBagKey,
|
||||
@@ -232,6 +233,18 @@ export async function createSupplierInvoiceRegistrationEntry(
|
||||
}
|
||||
}
|
||||
|
||||
// Särskild löneskatt på pensionskostnader (SLP, 24.26 %): items flagged
|
||||
// apply_slp (tjänstepensionspremier on 741x) get a self-balancing
|
||||
// 7533 D / 2514 K pair, injected the same way the reverse-charge pairs are.
|
||||
// The pair nets to zero, so the 2440 balance guarantee below keeps the
|
||||
// payable at the invoice total: SLP is the buyer's own tax, never part of
|
||||
// the supplier's fordran.
|
||||
const slpBase = slpBaseSek(items, invoice.currency, invoice.exchange_rate)
|
||||
if (slpBase > 0) {
|
||||
const slpLines = generateSlpLines(slpBase)
|
||||
lines.push(...slpLines.map((l) => ({ ...l, dimensions: defaultDimensions })))
|
||||
}
|
||||
|
||||
// Credit: Leverantörsskulder, balance guarantee: ensures sum(debits) === sum(credits)
|
||||
// For reverse charge, intermediate credits (2614/2624/2634) already exist, so we subtract them
|
||||
const totalDebits = lines.reduce((sum, l) => sum + l.debit_amount, 0)
|
||||
@@ -491,6 +504,16 @@ export async function createSupplierInvoiceCashEntry(
|
||||
}
|
||||
}
|
||||
|
||||
// Särskild löneskatt på pensionskostnader (SLP): same self-balancing
|
||||
// 7533 D / 2514 K pair as the registration entry, at the effective rate so
|
||||
// the base stays consistent with the expense lines above. Nets to zero:
|
||||
// the payment-account credit below is untouched.
|
||||
const slpBase = slpBaseSek(items, invoice.currency, effectiveRate)
|
||||
if (slpBase > 0) {
|
||||
const slpLines = generateSlpLines(slpBase)
|
||||
lines.push(...slpLines.map((l) => ({ ...l, dimensions: defaultDimensions })))
|
||||
}
|
||||
|
||||
// Öresavrundning: when translating a foreign invoice at the payment-date
|
||||
// rate, per-line rounding can drift the implied bank total by an öre or two.
|
||||
// Fold that residual into the largest expense line so the payment-account
|
||||
@@ -603,12 +626,24 @@ export async function createSupplierInvoicePrivatelyPaidEntry(
|
||||
}
|
||||
}
|
||||
|
||||
// Credit: Owner payable/equity, balance guarantee
|
||||
// Särskild löneskatt på pensionskostnader (SLP): same self-balancing
|
||||
// 7533 D / 2514 K pair as the registration entry. Nets to zero, so the
|
||||
// owner account below still carries exactly the expense + VAT total.
|
||||
const slpBase = slpBaseSek(items, invoice.currency, invoice.exchange_rate)
|
||||
if (slpBase > 0) {
|
||||
const slpLines = generateSlpLines(slpBase)
|
||||
lines.push(...slpLines.map((l) => ({ ...l, dimensions: defaultDimensions })))
|
||||
}
|
||||
|
||||
// Credit: Owner payable/equity, balance guarantee. Existing credits (the
|
||||
// SLP 2514 leg) are subtracted so the pair never inflates what the owner
|
||||
// is owed: same guarantee shape as the registration entry's 2440 line.
|
||||
const totalDebits = lines.reduce((sum, l) => sum + l.debit_amount, 0)
|
||||
const totalCredits = lines.reduce((sum, l) => sum + l.credit_amount, 0)
|
||||
lines.push({
|
||||
account_number: ownerAccount,
|
||||
debit_amount: 0,
|
||||
credit_amount: Math.round(totalDebits * 100) / 100,
|
||||
credit_amount: Math.round((totalDebits - totalCredits) * 100) / 100,
|
||||
line_description: desc,
|
||||
dimensions: defaultDimensions,
|
||||
})
|
||||
@@ -753,6 +788,30 @@ export async function createSupplierCreditNoteEntry(
|
||||
}
|
||||
}
|
||||
|
||||
// Reverse the SLP pair: registration booked 7533 D / 2514 K, so the credit
|
||||
// note books 7533 K / 2514 D (same debit/credit swap as basbeloppsraderna
|
||||
// above). Items are the ORIGINAL invoice's, so apply_slp reverses against
|
||||
// the same base. Nets to zero: the 2440 debit guarantee is untouched.
|
||||
//
|
||||
// The base is abs of the SIGNED sum, not the per-item abs the expense
|
||||
// buckets use: registration computed its SLP base as the signed sum, so a
|
||||
// mixed-sign flagged original (+10000 premium and -2000 rebate) booked SLP
|
||||
// on 8000. Per-item abs here would reverse SLP on 12000, striking more
|
||||
// 7533/2514 than was ever posted. The buckets keep per-item abs because
|
||||
// each reversal line must land positive on its own side per account.
|
||||
const slpBase = Math.abs(slpBaseSek(items, creditNote.currency, creditNote.exchange_rate))
|
||||
if (slpBase > 0) {
|
||||
for (const line of generateSlpLines(slpBase)) {
|
||||
lines.push({
|
||||
account_number: line.account_number,
|
||||
debit_amount: line.credit_amount,
|
||||
credit_amount: line.debit_amount,
|
||||
line_description: line.line_description,
|
||||
dimensions: defaultDimensions,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
lines.push(...creditLines)
|
||||
|
||||
// Debit: Leverantörsskulder, balance guarantee: debit = sum of credits minus other debits
|
||||
@@ -860,6 +919,30 @@ function groupBaseByRate(
|
||||
return baseByRate
|
||||
}
|
||||
|
||||
/**
|
||||
* Sum, in SEK, the base for särskild löneskatt på pensionskostnader: items
|
||||
* flagged apply_slp whose account is a 741x pension-premium account
|
||||
* (tjänstepensionspremier). The create routes reject apply_slp on any other
|
||||
* account; the account check here is defense in depth so a tampered or
|
||||
* legacy row can never SLP-flag an arbitrary expense.
|
||||
*/
|
||||
function slpBaseSek(
|
||||
items: SupplierInvoiceItem[],
|
||||
currency: string,
|
||||
exchangeRate: number | null | undefined,
|
||||
useAbsoluteValues = false
|
||||
): number {
|
||||
let base = 0
|
||||
for (const item of items) {
|
||||
if (item.apply_slp !== true) continue
|
||||
if (!isSlpPensionAccount(item.account_number)) continue
|
||||
let sek = toSekOrThrow(item.line_total, currency, exchangeRate)
|
||||
if (useAbsoluteValues) sek = Math.abs(sek)
|
||||
base += sek
|
||||
}
|
||||
return base
|
||||
}
|
||||
|
||||
/**
|
||||
* Sum, per VAT rate, the base (line_total in SEK) of items booked to
|
||||
* non-basis expense accounts. Items already booked to a 44xx/45xx basis
|
||||
|
||||
Reference in New Issue
Block a user