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
@@ -4,13 +4,18 @@ import {
|
||||
SLP_RATE,
|
||||
} from '../tax-provision/sarskild-loneskatt-calculator'
|
||||
|
||||
function makeSupabaseWithPensionLines(rows: Array<{ debit_amount: number; credit_amount: number }>) {
|
||||
function makeSupabaseWithPensionLines(
|
||||
rows: Array<{ account_number?: string; debit_amount: number; credit_amount: number }>,
|
||||
) {
|
||||
// The calculator uses the two-step entry-lines fetch
|
||||
// (lib/bookkeeping/entry-lines.ts): call 1 reads journal_entries, call 2
|
||||
// reads journal_entry_lines for those entry ids.
|
||||
// reads journal_entry_lines for those entry ids. One line query covers both
|
||||
// 7410-7419 (the base) and 7533 (SLP already posted); rows default to a
|
||||
// pension account when the fixture omits account_number.
|
||||
const withAccounts = rows.map((row) => ({ account_number: '7410', ...row }))
|
||||
const responses: Array<{ data: unknown; error: unknown }> = [
|
||||
{ data: [{ id: 'entry-1' }], error: null },
|
||||
{ data: rows, error: null },
|
||||
{ data: withAccounts, error: null },
|
||||
]
|
||||
let call = 0
|
||||
const makeBuilder = () => {
|
||||
@@ -74,6 +79,53 @@ describe('calculateSarskildLoneskatt', () => {
|
||||
expect(result!.amount).toBe(9_704)
|
||||
})
|
||||
|
||||
it('subtracts SLP already posted to 7533 during the year (apply_slp on supplier invoices)', async () => {
|
||||
// 100 000 kr premies booked during the year: 40 000 kr of them were
|
||||
// flagged apply_slp, so 40 000 × 0.2426 = 9 704 kr already sits on 7533.
|
||||
// The year-end proposal must cover ONLY the remaining 60 000 kr.
|
||||
const supabase = makeSupabaseWithPensionLines([
|
||||
{ account_number: '7412', debit_amount: 100_000, credit_amount: 0 },
|
||||
{ account_number: '7533', debit_amount: 9_704, credit_amount: 0 },
|
||||
])
|
||||
const result = await calculateSarskildLoneskatt(supabase, 'co', 'fp')
|
||||
expect(result).not.toBeNull()
|
||||
// 100_000 × 0.2426 − 9_704 = 24_260 − 9_704 = 14_556
|
||||
expect(result!.amount).toBe(14_556)
|
||||
const computation = result!.computation as { slpAlreadyPosted: number; base: number }
|
||||
expect(computation.slpAlreadyPosted).toBe(9_704)
|
||||
// Posted 7533 never shrinks the SLP BASE, only the proposal.
|
||||
expect(computation.base).toBe(100_000)
|
||||
})
|
||||
|
||||
it('returns null when the year is already fully provisioned', async () => {
|
||||
const supabase = makeSupabaseWithPensionLines([
|
||||
{ account_number: '7412', debit_amount: 10_000, credit_amount: 0 },
|
||||
{ account_number: '7533', debit_amount: 2_426, credit_amount: 0 },
|
||||
])
|
||||
const result = await calculateSarskildLoneskatt(supabase, 'co', 'fp')
|
||||
expect(result).toBeNull()
|
||||
})
|
||||
|
||||
it('floors at zero when 7533 exceeds the computed SLP (never a negative disposition)', async () => {
|
||||
const supabase = makeSupabaseWithPensionLines([
|
||||
{ account_number: '7412', debit_amount: 10_000, credit_amount: 0 },
|
||||
{ account_number: '7533', debit_amount: 5_000, credit_amount: 0 },
|
||||
])
|
||||
const result = await calculateSarskildLoneskatt(supabase, 'co', 'fp')
|
||||
expect(result).toBeNull()
|
||||
})
|
||||
|
||||
it('nets 7533 credits (a stornoed SLP pair does not count as provisioned)', async () => {
|
||||
const supabase = makeSupabaseWithPensionLines([
|
||||
{ account_number: '7412', debit_amount: 10_000, credit_amount: 0 },
|
||||
{ account_number: '7533', debit_amount: 2_426, credit_amount: 0 },
|
||||
{ account_number: '7533', debit_amount: 0, credit_amount: 2_426 },
|
||||
])
|
||||
const result = await calculateSarskildLoneskatt(supabase, 'co', 'fp')
|
||||
expect(result).not.toBeNull()
|
||||
expect(result!.amount).toBe(2_426)
|
||||
})
|
||||
|
||||
it('exposes the SLP rate constant', () => {
|
||||
expect(SLP_RATE).toBe(0.2426)
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user