feat(supplier-invoices): sarskild loneskatt (SLP) pair on pension premium lines (#1534)
* feat(supplier-invoices): sarskild loneskatt (SLP) pair on pension premium lines Booking a tjanstepension invoice (e.g. Avanza) needs the buyer's own SLP beyond the payable: debit 7533 / credit 2514 at 24.26% of the premium (SLF 1991:687). The item-based debit-only form could not express the self-balancing pair, so users had to hand-edit the verifikat. - new leaf module lib/bookkeeping/slp-lines.ts: SLP_RATE (single source, re-exported by the bokslut calculator), isSlpPensionAccount (741x), generateSlpLines (7533 D / 2514 K, nets to zero) - migration adds supplier_invoice_items.apply_slp boolean default false - registration, cash, and privately-paid generators inject the pair for flagged 741x items, mirroring the reverse-charge injection; the balance guarantees keep 2440/1930/2893 at exactly the invoice total; the credit note generator reverses the pair (7533 K / 2514 D) - privately-paid balance guarantee now subtracts existing credits so the SLP 2514 leg never inflates the owner account - schema field apply_slp + guards in all create paths (main route, inbox convert, v1 REST, pending-operations executor): 400 SI_CREATE_SLP_INVALID_ACCOUNT on non-741x accounts, 400 SI_CREATE_SLP_ACCRUAL combined with periodisering - form: advisory hint on unflagged 741x rows with one-click opt-in and a quiet confirmation line when applied; totals box untouched (the invoice total stays the payable); AB review preview injects the same pair via the same generator for parity - year-end double-count guard: calculateSarskildLoneskatt subtracts SLP already posted to 7533 during the year (floored at zero) so bokslut never provisions flagged premiums twice Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * chore(api-skill): regenerate suppliers reference for apply_slp The apiskill:check CI gate requires the generated accounted-api skill to stay in sync with the endpoint registry after the apply_slp addition. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(slp): carry apply_slp through v1 routes, MCP staging, preview and credit reversal Review findings on the SLP PR: - v1 credit route: SI_FULL_COLUMNS now projects items.apply_slp, so createSupplierCreditNoteEntry sees the flag and reverses the 7533/2514 pair booked at registration (it previously stood forever and the year-end netting under-provisioned). The flag is also copied onto the created credit-note items for parity with the web credit route. - v1 mark-paid: the items sub-select now includes apply_slp, so a kontantmetoden payment via v1 books the cash entry WITH the SLP pair, matching the web mark-paid. - v1 GET ?expand=items: SI_ITEM_COLUMNS includes apply_slp so the flag is readable back through the public API. - credit-note SLP base is abs of the SIGNED sum of flagged line_totals, not per-item abs: a mixed-sign flagged original (+10000/-2000) booked SLP on 8000 at registration and now reverses exactly that, not 12000. The expense-bucket per-item abs convention is untouched. - kontantmetod bank-match preview appends the same generateSlpLines pair the POST books, so the approved lines equal the committed lines. - MCP gnubok_create_supplier_invoice_from_inbox: line_overrides accepts apply_slp (optional boolean), plumbs it into the staged operation's items, and rejects non-741x resolved accounts at staging time with the bilingual SI_CREATE_SLP_INVALID_ACCOUNT texts. - DECISIONS.md: five entries for today's decisions. Every behavioral fix has a test verified to fail without it. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5
Jakob Wennberg
parent
3a7688c163
commit
7cf0e34434
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user