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:
@@ -876,5 +876,10 @@ One line per decision: `[YYYY-MM-DD] <decision>: <why>`. Appended by agents and
|
||||
[2026-08-11] Anthropic, Vercel and Supabase removed from the portal directory: all three email their invoices to European customers, so listing them told the user to go and log in for a document already in their inbox. The directory's bar is "does not send the invoice", not "also has a portal", and the poll it was seeded from asked which portals people log into, which people answered with where an invoice can ALSO be found. The same objection may reach further down the list; an entry is a claim that the invoice cannot be had any other way and is worth checking per vendor.
|
||||
[2026-08-11] Portal URLs are swept by scripts/check-portal-urls.mts rather than trusted: the directory shipped with 18 hand-written paths, none opened, the file said so and shipped anyway, and a founder then hit a 404 on Google Workspace (/ac/billing/history). A sweep found GitHub's /settings/billing 404 too. Rule now is the shallowest URL that certainly resolves: landing one click short of the invoice costs little, landing on an error page spends the trust the feature runs on. Google, OpenAI and Hetzner refuse automated requests, so they cannot be swept and are kept shallow deliberately; only a genuine 404 fails the script, since failing on an unreachable host would train people to ignore it. Trygg Hansa removed: neither candidate URL could be reached at all.
|
||||
[2026-08-11] Credit-note deduction fields (deduction_total, per-item deduction_amount) stay POSITIVE magnitudes, unlike every other amount on a credit note: both columns carry CHECK (>= 0) in the DB, and negating them made every ROT/RUT credit fail at insert (prod support case 2026-08-11). Verified inert: the reversing verifikat recomputes the ROT/RUT split from quantity/unit_price (generateRotRutLines), the PDF hides the deduction section for credit notes, getAmountToPay skips deductions when credited_invoice_id is set, and ROT payout candidates require status='paid', which invoices_credit_note_not_paid makes impossible for credit notes. Any future reader summing these fields across invoice + credit note must special-case credit notes.
|
||||
[2026-08-12] SLP on supplier invoices mirrors the reverse-charge zero-net pair mechanism (apply_slp item flag) instead of free-form credit rows: keeps the 2440 balance guarantee and the item model intact; year-end calculator nets off posted 7533 to avoid double provision.
|
||||
[2026-08-12] Enable Banking auth-method pin narrowed to hidden_method=true + psu_types match: PR #854's blanket decoupled pin broke Lunar-class banks; hidden-only restores its stated intent.
|
||||
[2026-08-12] Skattekonto bulk booking commits server-side per row (draft+commit in one request): avoids orphan drafts and 2N round trips; upcoming/duplicate-suspect/no-rule rows excluded.
|
||||
[2026-08-12] Kundfaktura status quick-filters stayed inside ContextPicker (counts + URL write-back), no chip row: founder lock "status views live behind a filter chip, not a seg".
|
||||
[2026-08-12] Detail pager uses sessionStorage list-context + router.replace, not server neighbor queries: the kundfaktura list order is client-computed and not expressible in PostgREST.
|
||||
[2026-08-12] Automatic logout is opt-in per user (user_preferences.auto_logout, default OFF), reversing the 2026-07 always-on session timeouts: founder decision after a user complaint about multiple daily re-logins. The objection that an opt-in security control is effectively a removed one was raised and overruled; the compromise levers kept are NEXT_PUBLIC_SESSION_TIMEOUT_FORCE_ALL=true (re-enables enforcement for everyone without a code change) and the opt-in snapshot living inside the signed timeout cookie (no per-request DB read; the preference is read only at mint, and the preferences API clears the cookie on change so a toggle takes effect on the next request). No backstop absolute cap was added: "off" means the Supabase refresh-token lifetime governs, exactly the pre-hardening behavior. Pre-toggle cookies (no autoLogout field) are authentic-but-stale and re-minted preserving their timers, never routed down the tamper path, so the rollout logs nobody out.
|
||||
[2026-08-12] Content dedupe on document ingest is an opt-in uploadDocument flag wired into the intake funnel (uploadAndExtract + mail-hunt ingest), NOT a unique index on (company_id, sha256_hash): archival callers (sent invoices, filings, bank exports) legitimately store repeating bytes and a blanket constraint would break them; the SELECT-then-insert race is accepted exactly as in the WhatsApp precedent. On a hit the funnel adopts the existing inbox item (callers always get a real inbox_item_id) or files an item against the existing document; the mail hunt skips outright since a second item would only duplicate work in Underlag.
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -38,6 +38,8 @@ import {
|
||||
findReverseChargeAccountWarningRows,
|
||||
findUnflaggedForeignZeroVatRows,
|
||||
} from '@/lib/vat/supplier-invoice-line-checks'
|
||||
import { SLP_RATE, isSlpPensionAccount } from '@/lib/bookkeeping/slp-lines'
|
||||
import { roundOre } from '@/lib/money'
|
||||
import { ArrowLeft, Plus, Trash2, ChevronDown, Loader2, Lock, AlertCircle, AlertTriangle, MessageCircle, Link2, CalendarClock, Tags, Paperclip } from 'lucide-react'
|
||||
import type { Supplier, BASAccount, VatTreatment, EntityType, InvoiceExtractionResult, FiscalPeriod } from '@/types'
|
||||
|
||||
@@ -58,6 +60,10 @@ interface LineItem {
|
||||
// (possibly empty) while the row's dimensions panel is open; the server
|
||||
// merges it over the invoice's default_dimensions at booking time.
|
||||
dimensions?: Record<string, string>
|
||||
// Särskild löneskatt på pensionskostnader: booking injects a self-balancing
|
||||
// 7533 D / 2514 K pair at 24.26 % of the line amount. Only offered on 741x
|
||||
// pension-premium accounts; never changes the invoice total.
|
||||
apply_slp?: boolean
|
||||
}
|
||||
|
||||
// The existing invoice surfaced on a duplicate-number conflict, used to drive
|
||||
@@ -842,6 +848,11 @@ export default function NewSupplierInvoiceForm({
|
||||
if (!watchedReverseCharge && defaultRate != null && Number.isFinite(defaultRate)) {
|
||||
setValue(`items.${index}.vat_rate`, defaultRate, { shouldDirty: true })
|
||||
}
|
||||
// Särskild löneskatt only applies to 741x pension premiums: leaving the
|
||||
// range clears the flag so a stale opt-in can never reach the API.
|
||||
if (watch(`items.${index}.apply_slp`) && !isSlpPensionAccount(accountNumber)) {
|
||||
setValue(`items.${index}.apply_slp`, undefined, { shouldDirty: true })
|
||||
}
|
||||
}
|
||||
|
||||
// Periodisering per rad: kräver faktureringsmetoden; eget utlägg bokar
|
||||
@@ -889,9 +900,71 @@ export default function NewSupplierInvoiceForm({
|
||||
suggestBalanceAccount('expense', account),
|
||||
{ shouldDirty: true },
|
||||
)
|
||||
// Periodisering + särskild löneskatt on the same row is rejected by the
|
||||
// API (SI_CREATE_SLP_ACCRUAL): opening the accrual panel wins and the
|
||||
// SLP opt-in is cleared (its confirmation row disappears with it).
|
||||
if (watch(`items.${index}.apply_slp`)) {
|
||||
setValue(`items.${index}.apply_slp`, undefined, { shouldDirty: true })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- Särskild löneskatt på pensionskostnader (SLP, 24,26 %) ---
|
||||
// A 741x pension-premium row gets an advisory hint (add the 7533/2514
|
||||
// pair) or, once opted in, a quiet confirmation line. The pair nets to
|
||||
// zero, so the totals box below is untouched: the invoice total IS the
|
||||
// payable. Hidden while the row's periodisering panel is open (the API
|
||||
// rejects the combination).
|
||||
function slpRowVisible(index: number): boolean {
|
||||
const item = watchedItems?.[index]
|
||||
if (!item) return false
|
||||
if (!isSlpPensionAccount(item.account_number || '')) return false
|
||||
if (canUseAccrual && isAccrualOpen(index)) return false
|
||||
return true
|
||||
}
|
||||
|
||||
function setSlp(index: number, value: boolean) {
|
||||
setValue(`items.${index}.apply_slp`, value ? true : undefined, { shouldDirty: true })
|
||||
}
|
||||
|
||||
function renderSlpPanel(index: number) {
|
||||
const item = watchedItems?.[index]
|
||||
if (!item) return null
|
||||
const slpAmount = roundOre((item.amount || 0) * SLP_RATE)
|
||||
if (item.apply_slp) {
|
||||
return (
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<p className="text-xs text-muted-foreground tabular-nums">
|
||||
{t('slp_applied_line', { amount: formatAmount(slpAmount) })}
|
||||
</p>
|
||||
<Button type="button" variant="ghost" size="sm" onClick={() => setSlp(index, false)}>
|
||||
{t('slp_remove_action')}
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<div
|
||||
role="status"
|
||||
className="flex items-start gap-2 rounded-lg border border-warning/30 bg-warning/10 p-3"
|
||||
>
|
||||
<AlertTriangle className="h-4 w-4 text-warning-foreground mt-0.5 shrink-0" />
|
||||
<p className="flex-1 text-sm text-warning-foreground">
|
||||
{t('slp_hint', { amount: formatAmount(slpAmount) })}
|
||||
</p>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="shrink-0"
|
||||
onClick={() => setSlp(index, true)}
|
||||
>
|
||||
{t('slp_add_action')}
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// --- Dimension tagging (kostnadsställe/projekt, dimensions PR7) ---
|
||||
// Mirrors the accrual pattern: a defined (possibly empty) bag on the item
|
||||
// means the row's panel is open; closing clears the bag entirely so the
|
||||
@@ -1120,6 +1193,14 @@ export default function NewSupplierInvoiceForm({
|
||||
...(item.dimensions && Object.keys(item.dimensions).length > 0
|
||||
? { dimensions: item.dimensions }
|
||||
: {}),
|
||||
// Särskild löneskatt (SLP): only sent when the opt-in is valid for
|
||||
// the row (741x account, no periodisering): a stale flag must never
|
||||
// 400 the submit.
|
||||
...(item.apply_slp &&
|
||||
isSlpPensionAccount(item.account_number) &&
|
||||
!(canUseAccrual && item.accrual_period_start && item.accrual_period_end)
|
||||
? { apply_slp: true }
|
||||
: {}),
|
||||
})),
|
||||
}
|
||||
}
|
||||
@@ -1947,7 +2028,7 @@ export default function NewSupplierInvoiceForm({
|
||||
<tbody>
|
||||
{fields.map((field, index) => (
|
||||
<Fragment key={field.id}>
|
||||
<tr className={cn('align-top', (canUseAccrual && isAccrualOpen(index)) || (dimensionsEnabled && isDimOpen(index)) ? 'border-0' : 'border-b last:border-0')}>
|
||||
<tr className={cn('align-top', (canUseAccrual && isAccrualOpen(index)) || (dimensionsEnabled && isDimOpen(index)) || slpRowVisible(index) ? 'border-0' : 'border-b last:border-0')}>
|
||||
<td className="py-2 pr-2">
|
||||
<Controller
|
||||
name={`items.${index}.account_number`}
|
||||
@@ -2069,12 +2150,19 @@ export default function NewSupplierInvoiceForm({
|
||||
</tr>
|
||||
)}
|
||||
{dimensionsEnabled && isDimOpen(index) && (
|
||||
<tr className="border-b last:border-0">
|
||||
<tr className={cn(slpRowVisible(index) ? 'border-0' : 'border-b last:border-0')}>
|
||||
<td colSpan={6} className="pb-3">
|
||||
{renderDimensionsPanel(index)}
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
{slpRowVisible(index) && (
|
||||
<tr className="border-b last:border-0">
|
||||
<td colSpan={6} className="pb-3">
|
||||
{renderSlpPanel(index)}
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</Fragment>
|
||||
))}
|
||||
</tbody>
|
||||
@@ -2206,6 +2294,7 @@ export default function NewSupplierInvoiceForm({
|
||||
{canUseAccrual && isAccrualOpen(index) &&
|
||||
renderAccrualPanel(index, `accrual-mobile-${index}`)}
|
||||
{dimensionsEnabled && isDimOpen(index) && renderDimensionsPanel(index)}
|
||||
{slpRowVisible(index) && renderSlpPanel(index)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
generateReverseChargeBasisLines,
|
||||
generateReverseChargeLines,
|
||||
} from '@/lib/bookkeeping/vat-entries'
|
||||
import { generateSlpLines, isSlpPensionAccount } from '@/lib/bookkeeping/slp-lines'
|
||||
import { buildSupplierDescription } from '@/lib/bookkeeping/supplier-invoice-description'
|
||||
import { resolveBookingAccount, itemHasAccrual } from '@/lib/bookkeeping/accruals/account-suggestions'
|
||||
import type { Supplier } from '@/types'
|
||||
@@ -27,6 +28,9 @@ interface ReviewLineItem {
|
||||
// Self-assessed VAT rate for omvänd skattskyldighet (0.06/0.12/0.25). The
|
||||
// supplier charges no VAT (vat_rate = 0); this drives the fiktiv-moms preview.
|
||||
reverse_charge_rate?: number
|
||||
// Särskild löneskatt på pensionskostnader: previews the self-balancing
|
||||
// 7533 D / 2514 K pair the engine injects for flagged 741x lines.
|
||||
apply_slp?: boolean
|
||||
// Periodisering: when both dates are set, the registration entry books the
|
||||
// net to the 17xx interim account instead of account_number (mirrored via
|
||||
// resolveBookingAccount so this preview matches the saved verifikat).
|
||||
@@ -114,6 +118,24 @@ function buildJournalPreview(
|
||||
? Math.round(item.vat_amount * 100) / 100
|
||||
: Math.round(item.amount * item.vat_rate * 100) / 100
|
||||
|
||||
// Särskild löneskatt på pensionskostnader: same self-balancing pair the
|
||||
// engine injects (7533 D / 2514 K at 24.26 % of flagged 741x lines), via
|
||||
// the same generator, so this preview matches the saved verifikat. The
|
||||
// pair nets to zero and never moves the 2440 credit below.
|
||||
const slpBase = items.reduce(
|
||||
(sum, item) =>
|
||||
item.apply_slp && isSlpPensionAccount(item.account_number)
|
||||
? sum + toSek(item.amount)
|
||||
: sum,
|
||||
0,
|
||||
)
|
||||
const slpPreviewLines: JournalPreviewLine[] = generateSlpLines(slpBase).map((sl) => ({
|
||||
account_number: sl.account_number,
|
||||
description: sl.line_description ?? sl.account_number,
|
||||
debit: sl.debit_amount,
|
||||
credit: sl.credit_amount,
|
||||
}))
|
||||
|
||||
if (reverseCharge) {
|
||||
// Reverse charge: the supplier charges no VAT, so the buyer self-assesses at
|
||||
// the Swedish statutory rate (resolveReverseChargeRate: 25% huvudregel
|
||||
@@ -170,6 +192,8 @@ function buildJournalPreview(
|
||||
}
|
||||
}
|
||||
|
||||
lines.push(...slpPreviewLines)
|
||||
|
||||
// Credit: 2440 at subtotal (no real VAT for reverse charge)
|
||||
lines.push({
|
||||
account_number: '2440',
|
||||
@@ -197,6 +221,8 @@ function buildJournalPreview(
|
||||
})
|
||||
}
|
||||
}
|
||||
lines.push(...slpPreviewLines)
|
||||
|
||||
// Credit: 2440 at total incl. VAT
|
||||
lines.push({
|
||||
account_number: '2440',
|
||||
|
||||
@@ -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 } : {}),
|
||||
}
|
||||
})
|
||||
|
||||
@@ -927,6 +927,23 @@ describe('CreateSupplierInvoiceItemSchema', () => {
|
||||
expect(result.success).toBe(true)
|
||||
})
|
||||
|
||||
it('accepts apply_slp as an optional boolean (särskild löneskatt opt-in)', () => {
|
||||
const flagged = CreateSupplierInvoiceItemSchema.safeParse(
|
||||
validSupplierInvoiceItem({ account_number: '7412', vat_rate: 0, apply_slp: true })
|
||||
)
|
||||
expect(flagged.success).toBe(true)
|
||||
|
||||
const omitted = CreateSupplierInvoiceItemSchema.safeParse(validSupplierInvoiceItem())
|
||||
expect(omitted.success).toBe(true)
|
||||
})
|
||||
|
||||
it('rejects non-boolean apply_slp', () => {
|
||||
const result = CreateSupplierInvoiceItemSchema.safeParse(
|
||||
validSupplierInvoiceItem({ apply_slp: 'yes' })
|
||||
)
|
||||
expect(result.success).toBe(false)
|
||||
})
|
||||
|
||||
it('works with quantity * unit_price line total', () => {
|
||||
const overByABit = CreateSupplierInvoiceItemSchema.safeParse(
|
||||
validSupplierInvoiceItem({
|
||||
|
||||
@@ -977,6 +977,12 @@ export const CreateSupplierInvoiceItemSchema = z.object({
|
||||
message: 'reverse_charge_rate must be 0.06, 0.12, or 0.25',
|
||||
})
|
||||
.optional(),
|
||||
// Särskild löneskatt på pensionskostnader (SLP): when true the booking
|
||||
// engine injects a self-balancing 7533 D / 2514 K pair at 24.26 % of the
|
||||
// line total (lib/bookkeeping/slp-lines.ts). The pair never changes the
|
||||
// payable. Routes reject the flag on non-741x accounts and in combination
|
||||
// with the periodisering fields below.
|
||||
apply_slp: z.boolean().optional(),
|
||||
vat_code: z.string().optional(),
|
||||
quantity: z.number().optional(),
|
||||
unit: z.string().optional(),
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
|
||||
@@ -1,9 +1,13 @@
|
||||
import type { SupabaseClient } from '@supabase/supabase-js'
|
||||
import { fetchEntryLines, type EntryLinesQuery } from '@/lib/bookkeeping/entry-lines'
|
||||
import { SLP_RATE } from '@/lib/bookkeeping/slp-lines'
|
||||
import { roundOre } from '@/lib/money'
|
||||
import type { ProposedDisposition } from '../types'
|
||||
|
||||
/** Särskild löneskatt på pensionskostnader (SLP). 24.26 % per SLF 1991:687. */
|
||||
export const SLP_RATE = 0.2426
|
||||
// Single source for the 24.26 % rate: lib/bookkeeping/slp-lines.ts (shared
|
||||
// with the supplier-invoice booking engine's per-line SLP pair). Re-exported
|
||||
// so existing imports from this module keep working.
|
||||
export { SLP_RATE }
|
||||
|
||||
export interface SlpComputation {
|
||||
/** Total pension cost during the period: sum of posted debits on accounts
|
||||
@@ -15,6 +19,10 @@ export interface SlpComputation {
|
||||
/** Base for SLP = pensionCostsBooked + manualAdjustment. */
|
||||
base: number
|
||||
rate: number
|
||||
/** SLP already posted to 7533 during the period (net of credits), e.g. by
|
||||
* supplier-invoice lines flagged apply_slp. Subtracted from the proposal
|
||||
* so bokslut never provisions the same premiums twice. */
|
||||
slpAlreadyPosted: number
|
||||
slpAmount: number
|
||||
}
|
||||
|
||||
@@ -36,8 +44,16 @@ export async function calculateSarskildLoneskatt(
|
||||
fiscalPeriodId: string,
|
||||
options: { manualAdjustment?: number } = {},
|
||||
): Promise<ProposedDisposition | null> {
|
||||
type Row = { debit_amount: number | string | null; credit_amount: number | string | null }
|
||||
// Two-step entry-lines fetch (see lib/bookkeeping/entry-lines.ts).
|
||||
type Row = {
|
||||
account_number: string | null
|
||||
debit_amount: number | string | null
|
||||
credit_amount: number | string | null
|
||||
}
|
||||
// Two-step entry-lines fetch (see lib/bookkeeping/entry-lines.ts). One
|
||||
// query covers both the SLP base (7410-7419) and the SLP already posted to
|
||||
// 7533 during the year (supplier-invoice lines flagged apply_slp book the
|
||||
// 7533/2514 pair at registration); the rows are partitioned below.
|
||||
const PENSION_ACCOUNTS = Array.from({ length: 10 }, (_, i) => `741${i}`)
|
||||
let data: Row[]
|
||||
try {
|
||||
data = await fetchEntryLines<Row>({
|
||||
@@ -49,7 +65,7 @@ export async function calculateSarskildLoneskatt(
|
||||
.eq('fiscal_period_id', fiscalPeriodId)
|
||||
.eq('status', 'posted'),
|
||||
filterLines: (q: EntryLinesQuery) =>
|
||||
q.gte('account_number', '7410').lte('account_number', '7419'),
|
||||
q.in('account_number', [...PENSION_ACCOUNTS, '7533']),
|
||||
attachEntriesAs: null,
|
||||
})
|
||||
} catch (err) {
|
||||
@@ -58,20 +74,32 @@ export async function calculateSarskildLoneskatt(
|
||||
)
|
||||
}
|
||||
|
||||
const pensionCostsBooked = data.reduce((sum, row) => {
|
||||
// Cost account: normal balance is debit, so net = debit − credit.
|
||||
return sum + ((Number(row.debit_amount) || 0) - (Number(row.credit_amount) || 0))
|
||||
}, 0)
|
||||
// Net = debit − credit (cost accounts have normal debit balance).
|
||||
const netDebit = (rows: Row[]) =>
|
||||
rows.reduce(
|
||||
(sum, row) => sum + ((Number(row.debit_amount) || 0) - (Number(row.credit_amount) || 0)),
|
||||
0,
|
||||
)
|
||||
const pensionCostsBooked = netDebit(
|
||||
data.filter((row) => row.account_number != null && row.account_number !== '7533'),
|
||||
)
|
||||
// Double-count guard: premiums flagged apply_slp on supplier invoices have
|
||||
// already booked their 7533/2514 pair during the year. Those debits sit on
|
||||
// 7533 in this same fiscal period, so subtracting them leaves exactly the
|
||||
// unprovisioned remainder. Floored at zero: an over-provisioned year never
|
||||
// proposes a negative disposition.
|
||||
const slpAlreadyPosted = netDebit(data.filter((row) => row.account_number === '7533'))
|
||||
|
||||
const manualAdjustment = options.manualAdjustment ?? 0
|
||||
const base = Math.max(0, pensionCostsBooked + manualAdjustment)
|
||||
const slpAmount = Math.round(base * SLP_RATE)
|
||||
const slpAmount = Math.max(0, Math.round(base * SLP_RATE - slpAlreadyPosted))
|
||||
|
||||
const computation: SlpComputation = {
|
||||
pensionCostsBooked: Math.round(pensionCostsBooked * 100) / 100,
|
||||
manualAdjustment,
|
||||
base,
|
||||
rate: SLP_RATE,
|
||||
slpAlreadyPosted: roundOre(slpAlreadyPosted),
|
||||
slpAmount,
|
||||
}
|
||||
|
||||
@@ -89,7 +117,10 @@ export async function calculateSarskildLoneskatt(
|
||||
account_number: '7533',
|
||||
debit_amount: slpAmount,
|
||||
credit_amount: 0,
|
||||
line_description: `SLP 24,26 % på ${base} kr pensionskostnader`,
|
||||
line_description:
|
||||
slpAlreadyPosted > 0
|
||||
? `SLP 24,26 % på ${base} kr pensionskostnader, minus ${computation.slpAlreadyPosted} kr redan bokförd SLP`
|
||||
: `SLP 24,26 % på ${base} kr pensionskostnader`,
|
||||
},
|
||||
{
|
||||
account_number: '2514',
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -2138,6 +2138,20 @@ const SUPPLIER_INVOICE_WAVE4: Record<string, StructuredErrorEntry> = {
|
||||
message_en:
|
||||
'Periodisering cannot be combined with reverse charge. The expense line carries the VAT base for the VAT declaration (boxes 20-32), so the net amount cannot be deferred to an interim account.',
|
||||
},
|
||||
SI_CREATE_SLP_INVALID_ACCOUNT: {
|
||||
httpStatus: 400,
|
||||
message_sv:
|
||||
'Särskild löneskatt kan bara läggas till på rader med pensionskonto 7410-7419 (t.ex. 7412 Premier för tjänstepensioner). Byt konto på raden eller ta bort löneskatten.',
|
||||
message_en:
|
||||
'Särskild löneskatt (payroll tax on pension costs) can only be added on lines booked to a pension account 7410-7419 (e.g. 7412 occupational pension premiums). Change the line account or remove the flag.',
|
||||
},
|
||||
SI_CREATE_SLP_ACCRUAL: {
|
||||
httpStatus: 400,
|
||||
message_sv:
|
||||
'Särskild löneskatt kan inte kombineras med periodisering på samma rad. Löneskatten (7533/2514) beräknas på hela radbeloppet vid registrering och kan inte skjutas upp.',
|
||||
message_en:
|
||||
'Särskild löneskatt cannot be combined with periodisering on the same line. The payroll tax (7533/2514) is computed on the full line amount at registration and cannot be deferred.',
|
||||
},
|
||||
SI_DELETE_HAS_BOOKING: {
|
||||
httpStatus: 400,
|
||||
message_sv:
|
||||
|
||||
@@ -43,6 +43,7 @@ import {
|
||||
resolveUnsettledStatus,
|
||||
} from '@/lib/supplier-invoices/lifecycle'
|
||||
import { coerceDimensionsBag } from '@/lib/bookkeeping/dimension-resolver'
|
||||
import { isSlpPensionAccount } from '@/lib/bookkeeping/slp-lines'
|
||||
import { cancelOrphanedPaymentEntry } from '@/lib/bookkeeping/cancel-orphaned-entry'
|
||||
import { runWithActor } from '@/lib/bookkeeping/actor-context-node'
|
||||
import type { CommitActor } from '@/lib/bookkeeping/actor-context'
|
||||
@@ -3383,6 +3384,21 @@ async function commitCreateSupplierInvoiceFromInbox(
|
||||
return { error: 'exchange_rate must be a finite number when provided', status: 400 }
|
||||
}
|
||||
|
||||
// Särskild löneskatt (SLP): staged params must respect the same rule the
|
||||
// create routes enforce; the 7533/2514 pair is only lawful on 741x pension
|
||||
// premiums, so a flag on any other account is tampered or mis-staged.
|
||||
const slpInvalid = rawItems.some(
|
||||
(item) => item.apply_slp === true && !isSlpPensionAccount(String(item.account_number ?? '')),
|
||||
)
|
||||
if (slpInvalid) {
|
||||
return {
|
||||
error:
|
||||
getErrorEntry('SI_CREATE_SLP_INVALID_ACCOUNT')?.message_sv ??
|
||||
'Särskild löneskatt kan bara läggas till på rader med pensionskonto 7410-7419.',
|
||||
status: 400,
|
||||
}
|
||||
}
|
||||
|
||||
// Idempotency: a re-fired commit (e.g. retry, double-click on the approval
|
||||
// UI, racy MCP call) must not create a second leverantörsfaktura for the
|
||||
// same inbox row. The DB FK on invoice_inbox_items.created_supplier_invoice_id
|
||||
@@ -3566,6 +3582,9 @@ async function commitCreateSupplierInvoiceFromInbox(
|
||||
reverse_charge_rate: reverseCharge
|
||||
? ([0.06, 0.12, 0.25].includes(Number(item.reverse_charge_rate)) ? Number(item.reverse_charge_rate) : null)
|
||||
: null,
|
||||
// Särskild löneskatt (SLP): booking injects the self-balancing
|
||||
// 7533/2514 pair for this line. Validated above (741x accounts only).
|
||||
apply_slp: item.apply_slp === true,
|
||||
dimensions: coerceDimensionsBag(item.dimensions) ?? {},
|
||||
}
|
||||
})
|
||||
|
||||
@@ -4165,6 +4165,10 @@
|
||||
"illegal_vat_rate_description": "Line {row} has VAT rate {rate} %. The legal Swedish VAT rates are 25, 12, 6 or 0 %.",
|
||||
"rc_account_warning": "Reverse charge: {count, plural, =1 {line {rows} uses an account starting with 1 or 6} other {lines {rows} use accounts starting with 1 or 6}}. Reverse charge purchases are normally booked on cost accounts (4xxx/5xxx). Double-check the account choice.",
|
||||
"foreign_zero_vat_warning": "Foreign supplier with no Swedish VAT: {count, plural, =1 {line {rows} has} other {lines {rows} have}} 0 % VAT while reverse charge is switched off. For a service bought from abroad, reverse charge should be on; otherwise boxes 20-24, 30-32 and 48 stay empty in the VAT return. If the supplier charged its own local VAT instead, or this is a goods import handled by customs, 0 % is correct and you can ignore this.",
|
||||
"slp_hint": "Pension insurance: add särskild löneskatt (24.26 %), {amount} kr. The tax is booked as its own line pair (7533/2514) and does not change the invoice total.",
|
||||
"slp_add_action": "Add payroll tax",
|
||||
"slp_applied_line": "7533 Särskild löneskatt D {amount} / 2514 C {amount}, does not affect the invoice total",
|
||||
"slp_remove_action": "Remove",
|
||||
"expense_registered_title": "Expense registered",
|
||||
"invoice_registered_title": "Invoice registered",
|
||||
"arrival_number_label": "Arrival number: {number}",
|
||||
|
||||
@@ -4165,6 +4165,10 @@
|
||||
"illegal_vat_rate_description": "Rad {row} har momssats {rate} %. Tillåtna momssatser är 25, 12, 6 eller 0 %.",
|
||||
"rc_account_warning": "Omvänd skattskyldighet: {count, plural, =1 {rad {rows} använder ett konto som börjar på 1 eller 6} other {raderna {rows} använder konton som börjar på 1 eller 6}}. Inköp med omvänd skattskyldighet bokförs normalt på kostnadskonton (4xxx/5xxx). Kontrollera kontovalet.",
|
||||
"foreign_zero_vat_warning": "Utländsk leverantör utan svensk moms: {count, plural, =1 {rad {rows} har} other {raderna {rows} har}} 0 % moms medan omvänd skattskyldighet är avstängd. Är det ett tjänsteköp från utlandet ska omvänd skattskyldighet vara på, annars blir ruta 20-24, 30-32 och 48 tomma i momsdeklarationen. Har leverantören i stället debiterat sin egen utländska moms, eller är det en varuimport som tullen hanterar, är 0 % rätt och du kan bortse från detta.",
|
||||
"slp_hint": "Pensionsförsäkring: lägg till särskild löneskatt (24,26 %), {amount} kr. Skatten bokförs som ett eget radpar (7533/2514) och påverkar inte fakturabeloppet.",
|
||||
"slp_add_action": "Lägg till löneskatt",
|
||||
"slp_applied_line": "7533 Särskild löneskatt D {amount} / 2514 K {amount}, påverkar inte fakturabeloppet",
|
||||
"slp_remove_action": "Ta bort",
|
||||
"expense_registered_title": "Utlägg registrerat",
|
||||
"invoice_registered_title": "Faktura registrerad",
|
||||
"arrival_number_label": "Ankomstnummer: {number}",
|
||||
|
||||
@@ -63,6 +63,7 @@ Creates a supplier invoice in `registered` status and posts the registration jou
|
||||
- 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.
|
||||
|
||||
| Parameter | In | Type | Required | Notes |
|
||||
|---|---|---|---|---|
|
||||
@@ -87,7 +88,7 @@ Request body:
|
||||
paid_with_private_funds?: boolean,
|
||||
payment_date?: string,
|
||||
default_dimensions?: Record<string, string>,
|
||||
items: { description: string, amount?: number, account_number: string, vat_rate?: 0 | 0.06 | 0.12 | 0.25, vat_amount?: number, reverse_charge_rate?: number, vat_code?: string, quantity?: number, unit?: string, unit_price?: number, accrual_period_start?: string, accrual_period_end?: string, accrual_balance_account?: string, dimensions?: Record<string, string> }[]
|
||||
items: { description: string, amount?: number, account_number: string, vat_rate?: 0 | 0.06 | 0.12 | 0.25, vat_amount?: number, reverse_charge_rate?: number, apply_slp?: boolean, vat_code?: string, quantity?: number, unit?: string, unit_price?: number, accrual_period_start?: string, accrual_period_end?: string, accrual_balance_account?: string, dimensions?: Record<string, string> }[]
|
||||
}
|
||||
```
|
||||
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
-- Särskild löneskatt på pensionskostnader (SLP) on supplier invoice lines.
|
||||
--
|
||||
-- Booking a tjänstepension invoice (e.g. Avanza) needs the buyer's own SLP
|
||||
-- pair beyond the payable: debit 7533 / credit 2514 at 24.26 % of the premium
|
||||
-- (SLF 1991:687). The pair nets to zero and must never raise 2440. This flag
|
||||
-- is the per-line opt-in the booking engine reads; it mirrors how reverse
|
||||
-- charge injects self-balancing fiktiv-moms pairs beyond the payable.
|
||||
|
||||
ALTER TABLE public.supplier_invoice_items
|
||||
ADD COLUMN apply_slp boolean NOT NULL DEFAULT false;
|
||||
|
||||
COMMENT ON COLUMN public.supplier_invoice_items.apply_slp IS
|
||||
'When true, booking the invoice injects a self-balancing särskild löneskatt pair (debit 7533 / credit 2514 at 24.26% of line_total) for this pension-premium line (BAS 7410-7419). The pair nets to zero and never changes the payable (2440).';
|
||||
@@ -1000,6 +1000,12 @@ export interface SupplierInvoiceItem {
|
||||
// the expense line this item books to (dimensions PR7). jsonb DEFAULT '{}'.
|
||||
dimensions?: Record<string, string>
|
||||
|
||||
// Särskild löneskatt på pensionskostnader: when true the booking engine
|
||||
// injects a self-balancing 7533 D / 2514 K pair at 24.26 % of line_total
|
||||
// (lib/bookkeeping/slp-lines.ts). Only valid on 741x pension-premium
|
||||
// accounts. Optional in TS for pre-migration fixtures.
|
||||
apply_slp?: boolean
|
||||
|
||||
created_at: string
|
||||
}
|
||||
|
||||
@@ -1510,6 +1516,9 @@ export interface CreateSupplierInvoiceItemInput {
|
||||
// Self-assessed VAT rate for omvänd skattskyldighet (0.06/0.12/0.25). When
|
||||
// set, the engine books fiktiv moms at this rate while vat_rate stays 0.
|
||||
reverse_charge_rate?: number
|
||||
// Särskild löneskatt på pensionskostnader: injects 7533 D / 2514 K at
|
||||
// 24.26 % of the line amount. Only valid on 741x pension accounts.
|
||||
apply_slp?: boolean
|
||||
vat_code?: string
|
||||
// Legacy fields (backward compat, ignored when amount is set)
|
||||
quantity?: number
|
||||
|
||||
Reference in New Issue
Block a user