Files
accounted/lib/bookkeeping/supplier-invoice-entries.ts
T
7cf0e34434 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>
2026-08-12 20:52:47 +02:00

968 lines
40 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { createJournalEntry, findFiscalPeriod } from './engine'
import { resolveSekAmount, buildCurrencyMetadata } from './currency-utils'
import { resolveBookingAccount } from './accruals/account-suggestions'
import { buildSupplierDescription } from './supplier-invoice-description'
import {
generateReverseChargeLines,
generateReverseChargeBasisLines,
isReverseChargeBasisAccount,
resolveReverseChargeRate,
} from './vat-entries'
import { generateSlpLines, isSlpPensionAccount } from './slp-lines'
import {
coerceDimensionsBag,
dimensionsBagKey,
mergeDimensionBags,
type LineDimensions,
} from './dimension-resolver'
import { createLogger } from '@/lib/logger'
import { roundOre } from '@/lib/money'
import type { SupabaseClient } from '@supabase/supabase-js'
import type {
CreateJournalEntryInput,
CreateJournalEntryLineInput,
JournalEntry,
SupplierInvoice,
SupplierInvoiceItem,
} from '@/types'
const log = createLogger('supplier-invoice-entries')
/**
* Stable code for the "foreign-currency supplier invoice without a rate"
* refusal. Registered in lib/errors/structured-errors.ts so REST routes, the
* MCP server and getErrorMessage() all translate it the same way.
*/
export const SI_FX_RATE_MISSING = 'SI_FX_RATE_MISSING' as const
/**
* Raised when a booking path is asked to translate a foreign-currency supplier
* invoice that has no usable exchange rate.
*
* resolveSekAmount() answers that case by returning the RAW foreign amount (a
* legacy "assume SEK" fallback that is tolerable in read-only code but not
* when posting a verifikat): a 1 000 EUR invoice would post as 1 000 SEK, and
* because every leg is scaled by the same wrong factor the entry still
* balances, so no DB trigger fires and nothing errors. Under omvänd
* skattskyldighet that silently books 250,00 kr of fiktiv moms on 2614/2645
* instead of 2 875,00 kr at 11,50 SEK/EUR, understating both ruta 20 (or 21)
* and ruta 30 of the momsdeklaration by the same amount: an oriktig uppgift
* exposed to skattetillägg under SFL 49 kap 4 §.
*
* The in-house precedent for failing loudly instead is the
* `match_batch_allocate` RPC, which hard-fails with BATCH_FX_RATE_MISSING, and
* lib/reports/supplier-ledger.ts, which skips any FX invoice lacking a rate
* rather than faking a 1:1 conversion.
*/
export class SupplierInvoiceFxRateMissingError extends Error {
readonly code = SI_FX_RATE_MISSING
constructor(public readonly currency: string) {
super(
`Supplier invoice is in ${currency} but has no exchange rate on file; refusing to post it as if 1 ${currency} = 1 SEK.`
)
this.name = 'SupplierInvoiceFxRateMissingError'
}
}
/**
* Convert an invoice-currency amount to SEK for a journal entry line.
*
* SEK invoices short-circuit exactly as before, and so does any invoice with a
* legitimately supplied positive rate: the only new behaviour is the refusal
* above when a foreign invoice reaches a booking path with no rate at all.
* Every conversion in this file goes through here so no leg (expense, moms,
* fiktiv moms, basbelopp, 2440) can be posted at a fabricated 1:1 rate.
*/
function toSekOrThrow(
amount: number,
currency: string,
exchangeRate: number | null | undefined
): number {
if (currency && currency !== 'SEK' && !(exchangeRate != null && exchangeRate > 0)) {
throw new SupplierInvoiceFxRateMissingError(currency)
}
return resolveSekAmount(amount, null, currency, exchangeRate)
}
/**
* Aggregate item amounts per (booking account, merged dimensions bag):
* dimensions PR7. The merged bag (item.dimensions over the invoice's
* default_dimensions) is part of the aggregation identity so two items on the
* same account but different tags stay on separate journal lines instead of
* collapsing. Insertion order is first-seen, matching the old per-account map.
*/
interface ExpenseBucket {
account: string
dimensions?: LineDimensions
amount: number
}
function groupExpenseBuckets(
items: SupplierInvoiceItem[],
resolveAccount: (item: SupplierInvoiceItem) => string,
toSek: (item: SupplierInvoiceItem) => number,
defaultDimensions?: LineDimensions
): ExpenseBucket[] {
const buckets = new Map<string, ExpenseBucket>()
for (const item of items) {
const account = resolveAccount(item)
const dimensions = mergeDimensionBags(defaultDimensions, item.dimensions)
const key = `${account}\u0000${dimensionsBagKey(dimensions)}`
const bucket = buckets.get(key) ?? { account, dimensions, amount: 0 }
bucket.amount += toSek(item)
buckets.set(key, bucket)
}
return [...buckets.values()]
}
/**
* Create journal entry when a supplier invoice is registered (accrual method)
*
* Swedish domestic (25% VAT):
* Debit 5xxx/6xxx (per item's account_number) [item line_total]
* Debit 2641 Ingående moms (per rate) [VAT per rate group]
* Credit 2440 Leverantörsskulder [total incl VAT]
*
* EU/non-EU reverse charge (services):
* Debit 5xxx/6xxx (per item) [total]
* Debit 2645 Beräknad ingående moms (per rate) [fiktiv VAT per rate]
* Credit 26x4 Utgående moms omvänd (per rate) [fiktiv VAT per rate]
* Credit 2440 Leverantörsskulder [total]
*
* Note: Goods imports via Tullverket (customs) use a different accounting path
* (2615/2645) and are not handled here: only services use reverse charge.
*/
export async function createSupplierInvoiceRegistrationEntry(
supabase: SupabaseClient,
companyId: string,
userId: string,
invoice: SupplierInvoice,
items: SupplierInvoiceItem[],
supplierType: string,
supplierName?: string
): Promise<JournalEntry | null> {
const fiscalPeriodId = await findFiscalPeriod(supabase, companyId, invoice.invoice_date)
if (!fiscalPeriodId) {
log.warn('No open fiscal period found for invoice date:', invoice.invoice_date)
return null
}
const lines: CreateJournalEntryLineInput[] = []
const desc = buildSupplierDescription('Leverantörsfaktura', invoice.supplier_invoice_number, supplierName, `(ankomstnr ${invoice.arrival_number})`)
const isForeign = invoice.currency !== 'SEK'
// Dimensions PR7: expense lines carry item bags merged over the invoice
// default; every other line (VAT, RC/basis, 2440) carries the default only.
const defaultDimensions = coerceDimensionsBag(invoice.default_dimensions)
// Aggregate expense amounts by (account, dimensions) and convert to SEK.
// Periodiserade lines book their net to the 17xx interim account instead
// of the cost account (resolveBookingAccount); VAT and 2440 are untouched:
// moms is never deferred (redovisas på fakturadatum).
const expenseBuckets = groupExpenseBuckets(
items,
(item) => resolveBookingAccount('expense', item, item.account_number),
(item) => toSekOrThrow(item.line_total, invoice.currency, invoice.exchange_rate),
defaultDimensions
)
// Debit: Expense accounts (in SEK)
const debitLines: CreateJournalEntryLineInput[] = []
for (const bucket of expenseBuckets) {
debitLines.push({
account_number: bucket.account,
debit_amount: Math.round(bucket.amount * 100) / 100,
credit_amount: 0,
line_description: desc,
dimensions: bucket.dimensions,
})
}
lines.push(...debitLines)
const isReverseCharge = (supplierType === 'eu_business' || supplierType === 'non_eu_business' || supplierType === 'swedish_business') && invoice.reverse_charge
const isDomesticRC = supplierType === 'swedish_business' && invoice.reverse_charge
if (isReverseCharge) {
// Reverse charge: fiktiv moms entries per rate group
// Domestic (byggtjänster etc.): 2647/26x4, EU/non-EU: 2645/26x4
//
// Also generate basbeloppsrader on 44xx/45xx + motkonto 4598 so SKV's
// momsdeklaration ruta 20-24 reflects the underlying purchase amount.
// Without these the fiktiv moms (2614/2624/2634) populates ruta 30-32
// but ruta 20-24 stay at 0, which Skatteverket rejects with felkod
// FK004 ("silent netting prohibited"; ML 13 kap kräver båda sidor).
//
// The basis-account check is done per (rate, account) bucket: if the user
// booked an item directly to a 44xx/45xx basis account at a given rate,
// that item's belopp already populates ruta 20-24 via the expense line:
// we only emit basbeloppsrader for the portion of that rate's base that
// went to NON-basis accounts. Mixed invoices (4535 + 6540 at 25%) used to
// skip basis lines entirely under a per-invoice flag, leaving ruta 30
// larger than ruta 21 by the 6540 portion, the exact FK004 pattern.
//
// Drive iteration off the basis (line_total per rate), not stored
// vat_amount: fiktiv moms is always statutory base × rate. This keeps
// RC immune to per-line manual VAT overrides (which only make sense for
// domestic deductible-VAT adjustments).
const baseByRate = groupBaseByRate(items, invoice.currency, invoice.exchange_rate)
const nonBasisBaseByRate = groupNonBasisBaseByRate(items, invoice.currency, invoice.exchange_rate)
const rcSupplierType = supplierType as 'eu_business' | 'non_eu_business' | 'swedish_business'
for (const [rate, baseAmount] of baseByRate) {
if (rate > 0 && baseAmount > 0) {
const rcLines = generateReverseChargeLines(baseAmount, rate, isDomesticRC)
lines.push(...rcLines.map((l) => ({ ...l, dimensions: defaultDimensions })))
const nonBasisBase = nonBasisBaseByRate.get(rate) || 0
if (nonBasisBase > 0) {
const basisLines = generateReverseChargeBasisLines(nonBasisBase, rate, rcSupplierType)
lines.push(...basisLines.map((l) => ({ ...l, dimensions: defaultDimensions })))
}
}
}
} else if (itemsHaveVat(items)) {
// Domestic standard: Debit ingående moms per rate group
const vatByRate = groupVatByRate(items, invoice.currency, invoice.exchange_rate)
for (const [rate, amount] of vatByRate) {
if (amount > 0) {
lines.push({
account_number: '2641',
debit_amount: Math.round(amount * 100) / 100,
credit_amount: 0,
line_description: `Ingående moms ${Math.round(rate * 100)}% ${desc}`,
dimensions: defaultDimensions,
})
}
}
}
// 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)
const totalCredits = lines.reduce((sum, l) => sum + l.credit_amount, 0)
lines.push({
account_number: '2440',
debit_amount: 0,
credit_amount: Math.round((totalDebits - totalCredits) * 100) / 100,
line_description: desc,
dimensions: defaultDimensions,
...buildCurrencyMetadata(invoice.currency, isForeign ? invoice.total : undefined, invoice.exchange_rate),
})
const input: CreateJournalEntryInput = {
fiscal_period_id: fiscalPeriodId,
entry_date: invoice.invoice_date,
description: desc,
source_type: 'supplier_invoice_registered',
source_id: invoice.id,
lines,
}
return createJournalEntry(supabase, companyId, userId, input)
}
/**
* Create journal entry when a supplier invoice is paid (accrual method)
*
* Debit 2440 Leverantörsskulder [payment amount]
* Credit 1930 Företagskonto [payment amount]
*
* With exchange rate difference:
* Debit 2440 Leverantörsskulder [original SEK amount]
* Credit 1930 Företagskonto [actual SEK paid]
* Credit/Debit 3960/7960 [difference]
*/
export async function createSupplierInvoicePaymentEntry(
supabase: SupabaseClient,
companyId: string,
userId: string,
invoice: SupplierInvoice,
paymentAmount: number,
paymentDate: string,
exchangeRateDifference?: number,
supplierName?: string,
paymentAccount?: string
): Promise<JournalEntry | null> {
const creditAccount = paymentAccount || '1930'
const fiscalPeriodId = await findFiscalPeriod(supabase, companyId, paymentDate)
if (!fiscalPeriodId) {
log.warn('No open fiscal period found for payment date:', paymentDate)
return null
}
const desc = buildSupplierDescription('Utbetalning leverantörsfaktura', invoice.supplier_invoice_number, supplierName, `(ankomstnr ${invoice.arrival_number})`)
const lines: CreateJournalEntryLineInput[] = []
// Dimensions PR7: the payment voucher re-propagates the linked invoice's
// default bag onto every leg (incl. FX result lines), see the stamp below.
const defaultDimensions = coerceDimensionsBag(invoice.default_dimensions)
if (exchangeRateDifference && exchangeRateDifference !== 0) {
// Foreign currency with exchange rate difference
const originalSekAmount = paymentAmount
const actualSekPaid = paymentAmount - exchangeRateDifference
// Debit: Clear leverantörsskulder at original booked SEK amount
lines.push({
account_number: '2440',
debit_amount: Math.round(originalSekAmount * 100) / 100,
credit_amount: 0,
line_description: desc,
})
// Credit: Bank at actual SEK paid
lines.push({
account_number: creditAccount,
debit_amount: 0,
credit_amount: Math.round(actualSekPaid * 100) / 100,
line_description: desc,
})
// Exchange rate difference
if (exchangeRateDifference > 0) {
// Gain: Credit 3960
lines.push({
account_number: '3960',
debit_amount: 0,
credit_amount: Math.round(Math.abs(exchangeRateDifference) * 100) / 100,
line_description: 'Valutakursvinst',
})
} else {
// Loss: Debit 7960
lines.push({
account_number: '7960',
debit_amount: Math.round(Math.abs(exchangeRateDifference) * 100) / 100,
credit_amount: 0,
line_description: 'Valutakursförlust',
})
}
} else {
// Standard SEK payment
lines.push({
account_number: '2440',
debit_amount: Math.round(paymentAmount * 100) / 100,
credit_amount: 0,
line_description: desc,
})
lines.push({
account_number: creditAccount,
debit_amount: 0,
credit_amount: Math.round(paymentAmount * 100) / 100,
line_description: desc,
})
}
if (defaultDimensions) {
// Copy per line: a shared bag object would let one line's mutation
// leak into every other line (same contract as proposal stamping).
for (const line of lines) line.dimensions = { ...defaultDimensions }
}
const input: CreateJournalEntryInput = {
fiscal_period_id: fiscalPeriodId,
entry_date: paymentDate,
description: desc,
source_type: 'supplier_invoice_paid',
source_id: invoice.id,
lines,
}
return createJournalEntry(supabase, companyId, userId, input)
}
/**
* Create journal entry for cash method (kontantmetoden)
* Combined entry at payment time:
*
* Debit 5xxx/6xxx (per item) [line_total]
* Debit 2641 Ingående moms [total VAT]
* Credit 1930 Företagskonto [total incl VAT]
*/
export async function createSupplierInvoiceCashEntry(
supabase: SupabaseClient,
companyId: string,
userId: string,
invoice: SupplierInvoice,
items: SupplierInvoiceItem[],
paymentDate: string,
supplierType: string,
supplierName?: string,
paymentAccount?: string,
// SEK that actually settled the invoice (the amount that left the bank). For
// a foreign-currency invoice this pins the whole entry to the PAYMENT-date
// rate, see the kontantmetoden note below. Omit for SEK invoices and the
// behaviour is byte-identical to before.
settledBankSek?: number
): Promise<JournalEntry | null> {
const creditAccount = paymentAccount || '1930'
const fiscalPeriodId = await findFiscalPeriod(supabase, companyId, paymentDate)
if (!fiscalPeriodId) {
log.warn('No open fiscal period found for payment date:', paymentDate)
return null
}
// Under kontantmetoden the booked affärshändelse IS the payment (BFL 5 kap:
// "bokföring vid betalningstillfället"), so the entire verifikat is translated
// at the PAYMENT-date rate (ÅRL 4 kap 6 §). There is no kursvinst/kursförlust
// because no leverantörsskuld was ever carried at a historical rate: that
// only happens under faktureringsmetoden (handled by the 2440-clearing path
// with 7960/3960). When the caller passes the SEK that actually settled the
// invoice, we derive the implied payment-date rate from it so the payment-
// account credit equals the bank movement to the öre. For SEK invoices, or
// when no settlement SEK is supplied, we keep the invoice's stored rate.
const isForeign = invoice.currency !== 'SEK'
const useSettlementRate =
settledBankSek != null && settledBankSek > 0 && isForeign && invoice.total > 0
const effectiveRate = useSettlementRate
? settledBankSek / invoice.total
: invoice.exchange_rate
const desc = buildSupplierDescription('Kontantbetalning leverantörsfaktura', invoice.supplier_invoice_number, supplierName)
const lines: CreateJournalEntryLineInput[] = []
// Dimensions PR7: kontantmetoden books the expense at payment, same merge
// rules as the registration entry.
const defaultDimensions = coerceDimensionsBag(invoice.default_dimensions)
// Expense debit lines tracked separately so a sub-öre translation residual
// can be folded into the largest one (öresavrundning step below).
const expenseLines: CreateJournalEntryLineInput[] = []
// Aggregate expense amounts by (account, dimensions) and convert to SEK
const expenseBuckets = groupExpenseBuckets(
items,
(item) => item.account_number,
(item) => toSekOrThrow(item.line_total, invoice.currency, effectiveRate),
defaultDimensions
)
// Debit: Expense accounts (in SEK)
for (const bucket of expenseBuckets) {
const line: CreateJournalEntryLineInput = {
account_number: bucket.account,
debit_amount: Math.round(bucket.amount * 100) / 100,
credit_amount: 0,
line_description: desc,
dimensions: bucket.dimensions,
}
lines.push(line)
expenseLines.push(line)
}
const isReverseCharge = (supplierType === 'eu_business' || supplierType === 'non_eu_business' || supplierType === 'swedish_business') && invoice.reverse_charge
const isDomesticRC = supplierType === 'swedish_business' && invoice.reverse_charge
if (isReverseCharge) {
// Reverse charge: fiktiv moms entries per rate group
// Domestic (byggtjänster etc.): 2647/26x4, EU/non-EU: 2645/26x4
//
// Also generate basbeloppsrader on 44xx/45xx + motkonto 4598 so SKV's
// momsdeklaration ruta 20-24 reflects the underlying purchase amount.
// Without these the fiktiv moms (2614/2624/2634) populates ruta 30-32
// but ruta 20-24 stay at 0, which Skatteverket rejects with felkod
// FK004 ("silent netting prohibited"; ML 13 kap kräver båda sidor).
// Per-rate bucketing: see registration entry above for the FK004 rationale.
// Drive iteration off the basis (line_total per rate): fiktiv moms is
// always statutory base × rate; manual vat_amount overrides don't apply.
// effectiveRate (payment-date rate under kontantmetoden) keeps the fiktiv
// moms base consistent with the expense lines above.
const baseByRate = groupBaseByRate(items, invoice.currency, effectiveRate)
const nonBasisBaseByRate = groupNonBasisBaseByRate(items, invoice.currency, effectiveRate)
const rcSupplierType = supplierType as 'eu_business' | 'non_eu_business' | 'swedish_business'
for (const [rate, baseAmount] of baseByRate) {
if (rate > 0 && baseAmount > 0) {
const rcLines = generateReverseChargeLines(baseAmount, rate, isDomesticRC)
lines.push(...rcLines.map((l) => ({ ...l, dimensions: defaultDimensions })))
const nonBasisBase = nonBasisBaseByRate.get(rate) || 0
if (nonBasisBase > 0) {
const basisLines = generateReverseChargeBasisLines(nonBasisBase, rate, rcSupplierType)
lines.push(...basisLines.map((l) => ({ ...l, dimensions: defaultDimensions })))
}
}
}
} else if (itemsHaveVat(items)) {
// Domestic standard: Debit ingående moms per rate group (at the payment-
// date rate when settling a foreign invoice, see effectiveRate above).
const vatByRate = groupVatByRate(items, invoice.currency, effectiveRate)
for (const [rate, amount] of vatByRate) {
if (amount > 0) {
lines.push({
account_number: '2641',
debit_amount: Math.round(amount * 100) / 100,
credit_amount: 0,
line_description: `Ingående moms ${Math.round(rate * 100)}% ${desc}`,
dimensions: defaultDimensions,
})
}
}
}
// 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
// credit lands exactly on the SEK that left the bank (1930 reconciles to the
// bank transaction). Immaterial to the momsdeklaration: rutor are whole
// kronor. The |residual| ≤ 1 guard ensures we only absorb rounding noise,
// never a real shortfall (a partial settlement is blocked upstream).
if (useSettlementRate && expenseLines.length > 0) {
const debitSum = lines.reduce((sum, l) => sum + l.debit_amount, 0)
const creditSum = lines.reduce((sum, l) => sum + l.credit_amount, 0)
const provisionalCredit = roundOre(debitSum - creditSum)
const residual = roundOre(settledBankSek! - provisionalCredit)
if (residual !== 0 && Math.abs(residual) <= 1) {
const target = expenseLines.reduce((a, b) => (b.debit_amount >= a.debit_amount ? b : a))
target.debit_amount = roundOre(target.debit_amount + residual)
}
}
// Credit: payment account, 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)
const totalCredits = lines.reduce((sum, l) => sum + l.credit_amount, 0)
lines.push({
account_number: creditAccount,
debit_amount: 0,
credit_amount: Math.round((totalDebits - totalCredits) * 100) / 100,
line_description: desc,
dimensions: defaultDimensions,
})
const input: CreateJournalEntryInput = {
fiscal_period_id: fiscalPeriodId,
entry_date: paymentDate,
description: desc,
source_type: 'supplier_invoice_cash_payment',
source_id: invoice.id,
lines,
}
return createJournalEntry(supabase, companyId, userId, input)
}
/**
* Create journal entry for an invoice paid with the owner's private funds
* (eget utlägg). The AP leg is bypassed entirely: instead of crediting 2440
* and later debiting it on mark-paid, the expense lines book straight against
* the owner's payable/equity account:
*
* Debit 5xxx/6xxx (per item) [line_total in SEK]
* Debit 2641 Ingående moms [VAT per rate]
* Credit 2893 / 2018 [total incl VAT]
*
* Reverse charge is intentionally not supported here. RC invoices are
* never "I paid this cash at a kiosk" cases: they're EU/byggtjänster from
* registered businesses with formal invoices, which always go through AP.
* The API route guards against this combo before calling us.
*/
export async function createSupplierInvoicePrivatelyPaidEntry(
supabase: SupabaseClient,
companyId: string,
userId: string,
invoice: SupplierInvoice,
items: SupplierInvoiceItem[],
entityType: 'aktiebolag' | 'enskild_firma',
supplierName?: string
): Promise<JournalEntry | null> {
const fiscalPeriodId = await findFiscalPeriod(supabase, companyId, invoice.invoice_date)
if (!fiscalPeriodId) {
log.warn('No open fiscal period found for invoice date:', invoice.invoice_date)
return null
}
const ownerAccount = entityType === 'aktiebolag' ? '2893' : '2018'
const desc = buildSupplierDescription('Eget utlägg', invoice.supplier_invoice_number, supplierName, `(ankomstnr ${invoice.arrival_number})`)
const lines: CreateJournalEntryLineInput[] = []
// Dimensions PR7: this IS the utlägg path, billable-expense-to-project
// tagging rides the same merge rules as the registration entry.
const defaultDimensions = coerceDimensionsBag(invoice.default_dimensions)
// Debit: Expense accounts (in SEK), aggregated per (account, dimensions)
const expenseBuckets = groupExpenseBuckets(
items,
(item) => item.account_number,
(item) => toSekOrThrow(item.line_total, invoice.currency, invoice.exchange_rate),
defaultDimensions
)
for (const bucket of expenseBuckets) {
lines.push({
account_number: bucket.account,
debit_amount: Math.round(bucket.amount * 100) / 100,
credit_amount: 0,
line_description: desc,
dimensions: bucket.dimensions,
})
}
// Debit: Ingående moms per rate group (mixed-rate kvitto support)
if (itemsHaveVat(items)) {
const vatByRate = groupVatByRate(items, invoice.currency, invoice.exchange_rate)
for (const [rate, amount] of vatByRate) {
if (amount > 0) {
lines.push({
account_number: '2641',
debit_amount: Math.round(amount * 100) / 100,
credit_amount: 0,
line_description: `Ingående moms ${Math.round(rate * 100)}% ${desc}`,
dimensions: defaultDimensions,
})
}
}
}
// 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 - totalCredits) * 100) / 100,
line_description: desc,
dimensions: defaultDimensions,
})
const input: CreateJournalEntryInput = {
fiscal_period_id: fiscalPeriodId,
entry_date: invoice.invoice_date,
description: desc,
source_type: 'supplier_invoice_privately_paid',
source_id: invoice.id,
lines,
}
return createJournalEntry(supabase, companyId, userId, input)
}
/**
* Create journal entry for a supplier credit note (reversal of registration)
*
* Debit 2440 Leverantörsskulder [total]
* Credit 5xxx/6xxx (per item) [line_total]
* Credit 2641 Ingående moms [total VAT]
*/
export async function createSupplierCreditNoteEntry(
supabase: SupabaseClient,
companyId: string,
userId: string,
creditNote: SupplierInvoice,
items: SupplierInvoiceItem[],
supplierType: string,
supplierName?: string
): Promise<JournalEntry | null> {
const fiscalPeriodId = await findFiscalPeriod(supabase, companyId, creditNote.invoice_date)
if (!fiscalPeriodId) {
log.warn('No open fiscal period found for credit note date:', creditNote.invoice_date)
return null
}
const desc = buildSupplierDescription('Kreditfaktura leverantör', creditNote.supplier_invoice_number, supplierName, `(ankomstnr ${creditNote.arrival_number})`)
const lines: CreateJournalEntryLineInput[] = []
// Dimensions PR7: items are the ORIGINAL invoice's (see below), so their
// bags reverse against the same dimension cells; the credit note's own
// default (copied from the original at credit time) rides the other legs.
const defaultDimensions = coerceDimensionsBag(creditNote.default_dimensions)
// Credit: Expense accounts (reverse, in SEK). The caller passes the
// ORIGINAL invoice's items so deferred lines reverse against the same 17xx
// interim account they were registered on (the schedule's posted
// dissolutions are stornoed separately by cancelSchedulesForSource).
const creditLines: CreateJournalEntryLineInput[] = []
const expenseBuckets = groupExpenseBuckets(
items,
(item) => resolveBookingAccount('expense', item, item.account_number),
(item) => Math.abs(toSekOrThrow(item.line_total, creditNote.currency, creditNote.exchange_rate)),
defaultDimensions
)
for (const bucket of expenseBuckets) {
creditLines.push({
account_number: bucket.account,
debit_amount: 0,
credit_amount: Math.round(bucket.amount * 100) / 100,
line_description: desc,
dimensions: bucket.dimensions,
})
}
const isReverseCharge = (supplierType === 'eu_business' || supplierType === 'non_eu_business' || supplierType === 'swedish_business') && creditNote.reverse_charge
const isDomesticRC = supplierType === 'swedish_business' && creditNote.reverse_charge
if (isReverseCharge) {
// Reverse the fiktiv moms per rate group (swap debit/credit from registration)
// Input VAT account: 2647 for domestic RC, 2645 for EU/non-EU
// Drive iteration off the basis: fiktiv moms is always statutory base × rate.
const inputAccount = isDomesticRC ? '2647' : '2645'
const baseByRate = groupBaseByRate(items, creditNote.currency, creditNote.exchange_rate, true)
const nonBasisBaseByRate = groupNonBasisBaseByRate(items, creditNote.currency, creditNote.exchange_rate, true)
const rcSupplierType = supplierType as 'eu_business' | 'non_eu_business' | 'swedish_business'
// Only reverse basbeloppsraderna for the portion the registration would
// have emitted them, namely the non-basis-account base per rate. Items
// booked directly to 44xx/45xx had no parallel basis lines in registration
// and so are reversed only via the expense credit line above.
for (const [rate, baseAmount] of baseByRate) {
if (rate > 0 && baseAmount > 0) {
const fiktivVat = Math.round(baseAmount * rate * 100) / 100
// Determine the output account for this rate
let outputAccount: string
switch (rate) {
case 0.12: outputAccount = '2624'; break
case 0.06: outputAccount = '2634'; break
default: outputAccount = '2614'; break
}
creditLines.push({
account_number: inputAccount,
debit_amount: 0,
credit_amount: fiktivVat,
line_description: `Omvänd fiktiv ingående moms ${Math.round(rate * 100)}% ${desc}`,
dimensions: defaultDimensions,
})
lines.push({
account_number: outputAccount,
debit_amount: fiktivVat,
credit_amount: 0,
line_description: `Omvänd fiktiv utgående moms ${Math.round(rate * 100)}% ${desc}`,
dimensions: defaultDimensions,
})
const nonBasisBase = nonBasisBaseByRate.get(rate) || 0
if (nonBasisBase > 0) {
// Reverse the basbeloppsrader (44xx/45xx debit & 4598 credit on the
// registration entry become credits & debits here). Without this the
// credit note would only undo the VAT amounts (ruta 30-32 + 48) but
// leave ruta 20-24 still showing the original basbelopp, exactly
// the same FK004-style mismatch the registration fix prevents.
const basisLines = generateReverseChargeBasisLines(nonBasisBase, rate, rcSupplierType)
// Swap debit/credit on every basis line so the credit note nets
// against the original registration verifikat.
for (const line of basisLines) {
lines.push({
account_number: line.account_number,
debit_amount: line.credit_amount,
credit_amount: line.debit_amount,
line_description: line.line_description,
dimensions: defaultDimensions,
})
}
}
}
}
} else {
// Domestic: Credit ingående moms per rate group (reverse)
const vatByRate = groupVatByRate(items, creditNote.currency, creditNote.exchange_rate, true)
for (const [rate, amount] of vatByRate) {
if (amount > 0) {
creditLines.push({
account_number: '2641',
debit_amount: 0,
credit_amount: amount,
line_description: `Ingående moms ${Math.round(rate * 100)}% ${desc}`,
dimensions: defaultDimensions,
})
}
}
}
// 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
const totalCredits = lines.reduce((sum, l) => sum + l.credit_amount, 0)
const totalDebits = lines.reduce((sum, l) => sum + l.debit_amount, 0)
lines.unshift({
account_number: '2440',
debit_amount: Math.round((totalCredits - totalDebits) * 100) / 100,
credit_amount: 0,
line_description: desc,
dimensions: defaultDimensions,
})
const input: CreateJournalEntryInput = {
fiscal_period_id: fiscalPeriodId,
entry_date: creditNote.invoice_date,
description: desc,
source_type: 'supplier_credit_note',
source_id: creditNote.id,
lines,
}
return createJournalEntry(supabase, companyId, userId, input)
}
/**
* Group items by VAT rate and sum the stored VAT amount per rate.
* Returns a Map<rate, totalVatAmount> in SEK for per-rate 2641 journal lines.
*
* Reads `item.vat_amount` directly: set by the API from the line's manual
* override when present, else computed line_total × rate. This is the path
* for partial-deductible cases (bilförmån 50%, representation 300 kr-tak),
* foreign-currency rounding, and supplier POS rounding.
*
* Fallback to line_total × rate when vat_amount is null/0 but rate > 0:
* legacy import paths (SIE, CSV, demo seed) sometimes leave vat_amount at
* the column DEFAULT of 0. Silently dropping input VAT to 2641 would
* understate ruta 48 in the momsdeklaration.
*
* Reverse-charge fiktiv moms doesn't use this: see groupBaseByRate, which
* derives the basis directly so fiktiv VAT is always base × statutory rate.
*/
/**
* True if any item would produce a non-zero ingående moms line: mirrors the
* same stored-vs-computed fallback groupVatByRate uses (stored vat_amount,
* else line_total × rate). Callers must gate the VAT branch on this instead
* of invoice.vat_amount: that header field can come from a source (e.g. the
* MCP inbox-conversion tool's OCR-extracted totals) that is never
* reconciled against the items, so a stale or zero header would otherwise
* silently suppress a correct per-line VAT posting.
*/
function itemsHaveVat(items: SupplierInvoiceItem[]): boolean {
return items.some((item) => {
if ((item.vat_amount ?? 0) > 0) return true
const rate = item.vat_rate ?? 0.25
return rate > 0 && (item.line_total ?? 0) > 0
})
}
function groupVatByRate(
items: SupplierInvoiceItem[],
currency: string,
exchangeRate: number | null,
useAbsoluteValues = false
): Map<number, number> {
const vatByRate = new Map<number, number>()
for (const item of items) {
const rate = item.vat_rate ?? 0.25
const storedVat = item.vat_amount ?? 0
const computedVat = rate > 0
? Math.round((item.line_total ?? 0) * rate * 100) / 100
: 0
const sourceVat = storedVat > 0 ? storedVat : computedVat
let vatSek = toSekOrThrow(sourceVat, currency, exchangeRate)
if (useAbsoluteValues) vatSek = Math.abs(vatSek)
vatByRate.set(rate, (vatByRate.get(rate) || 0) + vatSek)
}
return vatByRate
}
/**
* Group items by their self-assessed reverse-charge rate and sum the base
* (line_total) per rate. Used by reverse-charge paths to compute fiktiv moms
* from the basis, decoupled from any manual VAT override on the items.
*
* The grouping key is the *self-assessed* rate (resolveReverseChargeRate), not
* the line's vat_rate: under omvänd skattskyldighet the supplier charges 0%, so
* the line vat_rate is 0, but the buyer self-assesses at 25% (huvudregeln) or
* the explicit per-item reverse_charge_rate. Without this a 0%-rate RC line
* would key on rate 0 and the `rate > 0` guard below would skip its VAT lines.
*/
function groupBaseByRate(
items: SupplierInvoiceItem[],
currency: string,
exchangeRate: number | null,
useAbsoluteValues = false
): Map<number, number> {
const baseByRate = new Map<number, number>()
for (const item of items) {
const rate = resolveReverseChargeRate(item)
let baseSek = toSekOrThrow(item.line_total, currency, exchangeRate)
if (useAbsoluteValues) baseSek = Math.abs(baseSek)
baseByRate.set(rate, (baseByRate.get(rate) || 0) + baseSek)
}
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
* account populate ruta 20-24 directly via the expense line, so they must be
* excluded here to avoid double-counting in basbeloppsraderna.
*/
function groupNonBasisBaseByRate(
items: SupplierInvoiceItem[],
currency: string,
exchangeRate: number | null,
useAbsoluteValues = false
): Map<number, number> {
const baseByRate = new Map<number, number>()
for (const item of items) {
if (isReverseChargeBasisAccount(item.account_number)) continue
const rate = resolveReverseChargeRate(item)
let itemSek = toSekOrThrow(item.line_total, currency, exchangeRate)
if (useAbsoluteValues) itemSek = Math.abs(itemSek)
baseByRate.set(rate, (baseByRate.get(rate) || 0) + itemSek)
}
return baseByRate
}