Files
accounted/lib/bookkeeping/template-library.ts
T
Mattsson 3ff12faa77 fix(bookkeeping): reverse-charge VAT in booking templates is 25% of the base, not 20% (#1494)
* fix(bookkeeping): reverse-charge VAT in booking templates is 25% of the base, not 20%

applyTemplate() extracted VAT out of the total (rate/(1+rate)) for every
vat line, including the fiktiv-moms pair of reverse-charge templates.
Under omvand skattskyldighet the supplier charges no VAT, so the total IS
the beskattningsunderlag: on 807.99 kr the seeded EU-purchase template
booked 161.60 kr (20%) on 2614/2645 instead of 202.00 kr (25%),
understating Ruta 30-32 and Ruta 48 on the momsdeklaration.

Fiktiv-moms lines (2614/2624/2634 output, 2615/2625/2635 import,
2645/2647 input) now compute amount x rate on top of the base.
deriveTemplateLinesFromBooking ("Spara som mall") gets the mirror fix:
RC legs no longer inflate the derived total (they net to zero), and RC
rates snap against the base, so a correct RC booking round-trips.

Counterparty/SIE learned patterns already strip RC legs and regenerate
them via generateReverseChargeLines with the gross base; those paths
were correct and are unchanged.

User-reported: "Er automatiska utrakning ar pa 20%, inte 25%".

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(bookkeeping): use roundOre for template VAT rounding (guard ratchet)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* docs(bookkeeping): clarify fiktiv-moms comment: total is the base, booked amount is the VAT

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-10 13:59:55 +02:00

305 lines
12 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 type { BookingTemplateCategory, BookingTemplateLibrary, BookingTemplateLibraryLine, VatTreatment } from '@/types'
import type { BookingTemplate } from '@/lib/bookkeeping/booking-templates'
import type { FormLine } from '@/components/bookkeeping/JournalEntryForm'
import { isReverseChargeVatAccount } from '@/lib/bookkeeping/vat-entries'
import { roundOre } from '@/lib/money'
/**
* Prefix for library template ids when they are mapped into the
* static BookingTemplate shape used by the transaction picker.
*/
export const LIBRARY_TEMPLATE_PREFIX = 'library:'
export function isLibraryTemplateId(id: string): boolean { return id.startsWith(LIBRARY_TEMPLATE_PREFIX) }
/**
* Category labels in Swedish for UI display.
*/
export const TEMPLATE_CATEGORY_LABELS: Record<BookingTemplateCategory, string> = {
eu_trade: 'EU-handel',
tax_account: 'Skattekonto',
private_transfer: 'Egna transaktioner',
salary: 'Lön',
representation: 'Representation',
year_end: 'Bokslut',
vat: 'Moms',
financial: 'Bank & finans',
other: 'Övrigt',
}
/**
* Convert a template's line pattern + total amount into form lines
* ready for the JournalEntryForm.
*
* The algorithm:
* 1. VAT lines on ordinary accounts: amount = totalAmount × vat_rate / (1 + vat_rate)
* (the total is VAT-inclusive; the VAT is extracted out of it)
* 2. VAT lines on fiktiv-moms accounts (reverse charge/import, 2614 etc.):
* amount = totalAmount × vat_rate. Under omvänd skattskyldighet the
* supplier charged no VAT, so the total IS the beskattningsunderlag and
* the self-assessed VAT goes on top, netting to zero across the
* offsetting 2645/2614 pair. Extracting rate/(1+rate) here understates
* Ruta 30-32 and Ruta 48 by rate/(1+rate) (25% became 20%).
* 3. Settlement lines: amount = totalAmount (the full payment)
* 4. Business lines: amount = totalAmount × ratio (cost/revenue net of VAT handled separately)
*
* For simple two-line templates (no VAT), the ratio is typically 1.0
* on both sides and totalAmount is used directly.
*/
export function applyTemplate(
lines: BookingTemplateLibraryLine[],
totalAmount: number,
): FormLine[] {
const result: FormLine[] = []
for (const line of lines) {
let amount = 0
if (line.type === 'vat' && line.vat_rate) {
amount = isReverseChargeVatAccount(line.account)
? // Self-assessed VAT on top of the base (the total)
roundOre(totalAmount * line.vat_rate)
: // VAT extracted from the total inclusive amount
roundOre(totalAmount * line.vat_rate / (1 + line.vat_rate))
} else if (line.type === 'settlement') {
amount = Math.round(totalAmount * (line.ratio ?? 1) * 100) / 100
} else {
// Business lines: use ratio (default 1.0)
amount = Math.round(totalAmount * (line.ratio ?? 1) * 100) / 100
}
result.push({
account_number: line.account,
debit_amount: line.side === 'debit' ? amount.toFixed(2) : '',
credit_amount: line.side === 'credit' ? amount.toFixed(2) : '',
line_description: line.label,
})
}
return result
}
/**
* Scope label for displaying where a template comes from.
*/
export function getTemplateScope(template: {
is_system: boolean
team_id: string | null
company_id: string | null
}): 'system' | 'team' | 'company' {
if (template.is_system) return 'system'
if (template.team_id) return 'team'
return 'company'
}
export const SCOPE_LABELS: Record<ReturnType<typeof getTemplateScope>, string> = {
system: 'Standard',
team: 'Team',
company: 'Företag',
}
function vatRateToTreatment(rate: number): VatTreatment | null {
if (rate === 0.25) return 'standard_25'
if (rate === 0.12) return 'reduced_12'
if (rate === 0.06) return 'reduced_6'
return null
}
/**
* Convert a user-created library template to the BookingTemplate shape the
* transaction TemplatePicker consumes.
*
* Only simple shapes (one business line + one settlement line, optionally
* one VAT line) are returned: complex multi-account templates cannot be
* expressed as a single debit/credit pair and must be applied via the full
* journal entry form instead.
*
* The id is prefixed with "library:" so downstream code can recognise a
* library template and look it up through the library APIs rather than the
* static registry.
*/
export function convertLibraryToBookingTemplate(
lib: BookingTemplateLibrary,
): BookingTemplate | null {
if (!Array.isArray(lib.lines)) return null
const business = lib.lines.filter((l) => l.type === 'business')
const settlement = lib.lines.filter((l) => l.type === 'settlement')
const vat = lib.lines.filter((l) => l.type === 'vat')
if (business.length !== 1 || settlement.length !== 1) return null
if (business[0].side === settlement[0].side) return null
const debitLine = business[0].side === 'debit' ? business[0] : settlement[0]
const creditLine = business[0].side === 'credit' ? business[0] : settlement[0]
const direction: 'expense' | 'income' = business[0].side === 'debit' ? 'expense' : 'income'
let vatTreatment: VatTreatment | null = null
let vatRate = 0
if (vat.length > 0) {
const inputVat = vat.find((v) => v.side === 'debit' && v.vat_rate)
?? vat.find((v) => v.vat_rate)
if (inputVat?.vat_rate) {
const treatment = vatRateToTreatment(inputVat.vat_rate)
if (treatment) {
vatTreatment = treatment
vatRate = inputVat.vat_rate
}
}
// Reverse-charge is recognised by the presence of 2614/2624/2634 (fictitious output VAT)
if (vat.some((v) => v.account === '2614' || v.account === '2624' || v.account === '2634')) {
vatTreatment = 'reverse_charge'
}
}
return {
id: `${LIBRARY_TEMPLATE_PREFIX}${lib.id}`,
name_sv: lib.name,
name_en: lib.name,
group: 'financial',
direction,
entity_applicability: lib.entity_type ?? 'all',
debit_account: debitLine.account,
credit_account: creditLine.account,
vat_treatment: vatTreatment,
vat_rate: vatRate,
deductibility: 'full',
special_rules_sv: lib.description || undefined,
mcc_codes: [],
keywords: [],
risk_level: 'NONE',
requires_review: false,
impact_score: 0,
auto_match_confidence: 0,
default_private: false,
fallback_category: direction === 'expense' ? 'expense_other' : 'income_services',
description_sv: lib.description || '',
common: false,
}
}
// ---------------------------------------------------------------------------
// Deriving a template from a concrete booking
// ---------------------------------------------------------------------------
/** A single concrete booking row, as produced by the manual-booking forms
* (BookDirectlyDialog / JournalEntryForm). Amounts are strings straight from
* the inputs; either debit or credit is set, not both. */
export interface BookingRowInput {
account_number: string
debit_amount: string
credit_amount: string
}
/** Standard Swedish VAT rates a template line can carry (matches the rate
* selector in the template editor). 0 = no/foreign VAT. */
const STANDARD_VAT_RATES = [0.25, 0.12, 0.06, 0] as const
/**
* Snap a VAT line's implied rate to the nearest standard rate. For an ordinary
* VAT line the implied rate is vatAmount / net where net = total vatAmount
* (the same relationship applyTemplate inverts: vat = total × rate / (1 + rate)).
* For a fiktiv-moms line (reverse charge/import) the total IS the base, so the
* implied rate is vatAmount / total (applyTemplate inverts: vat = total × rate).
*/
function snapVatRate(vatAmount: number, total: number, isReverseCharge: boolean): number {
const base = isReverseCharge ? total : total - vatAmount
const implied = base > 0 ? vatAmount / base : 0
return STANDARD_VAT_RATES.reduce<number>(
(best, rate) => (Math.abs(rate - implied) < Math.abs(best - implied) ? rate : best),
0.25,
)
}
/**
* Derive amount-parameterised template lines from a concrete, balanced set of
* booking rows — the inverse of {@link applyTemplate}, used by "Spara som mall".
*
* A booking stores literal debit/credit amounts; a template stores ratios of a
* total plus VAT rates. The mapping:
* - total = the larger of Σdebit / Σcredit (equal when the entry balances),
* excluding fiktiv-moms legs (reverse charge/import): they net to zero and
* are no part of the payment, so counting them would inflate the total and
* shrink every derived business ratio
* - a 26xx line → a VAT line, its rate snapped to the nearest standard rate
* - the single non-VAT line closest to the total → the settlement leg (the
* bank / counter account), ratio 1
* - every other non-VAT line → a business (cost/revenue) line, ratio = amount/total
*
* Which non-VAT leg is tagged settlement vs business is only cosmetic — a
* template books its literal accounts regardless of the tag (see the
* "blind to business/settlement tagging" regression). The classification is a
* best-effort starting point that the user reviews and can re-tag in the editor
* before saving.
*
* Rows without a 4-digit account or with no amount are dropped. Returns [] when
* fewer than two usable lines remain or the total is non-positive.
*/
export function deriveTemplateLinesFromBooking(
rows: BookingRowInput[],
accountNames: Record<string, string> = {},
): BookingTemplateLibraryLine[] {
const parsed = rows
.map((row) => {
const account = row.account_number.trim()
const debit = Math.abs(parseFloat(row.debit_amount) || 0)
const credit = Math.abs(parseFloat(row.credit_amount) || 0)
const side: 'debit' | 'credit' = debit >= credit ? 'debit' : 'credit'
return { account, side, amount: Math.max(debit, credit) }
})
.filter((row) => /^\d{4}$/.test(row.account) && row.amount > 0)
if (parsed.length < 2) return []
const countsTowardTotal = (r: { account: string }) => !isReverseChargeVatAccount(r.account)
const sumDebit = parsed.reduce((s, r) => (r.side === 'debit' && countsTowardTotal(r) ? s + r.amount : s), 0)
const sumCredit = parsed.reduce((s, r) => (r.side === 'credit' && countsTowardTotal(r) ? s + r.amount : s), 0)
const total = roundOre(Math.max(sumDebit, sumCredit))
if (total <= 0) return []
const isVat = (account: string) => account.startsWith('26')
// Pick the settlement leg among the non-VAT lines: the one closest to the
// total (the bank / counter account). Equal distances prefer a credit leg,
// then the later row.
let settlementIndex = -1
let bestDistance = Infinity
let bestIsCredit = false
parsed.forEach((row, index) => {
if (isVat(row.account)) return
const distance = Math.abs(row.amount - total)
const closer = distance < bestDistance - 0.005
const tiePreferCredit =
Math.abs(distance - bestDistance) <= 0.005 && (row.side === 'credit' || !bestIsCredit)
if (settlementIndex === -1 || closer || tiePreferCredit) {
settlementIndex = index
bestDistance = distance
bestIsCredit = row.side === 'credit'
}
})
const label = (account: string) => accountNames[account]?.trim() || account
return parsed.map((row, index) => {
if (isVat(row.account)) {
return {
account: row.account,
label: label(row.account),
side: row.side,
type: 'vat',
vat_rate: snapVatRate(row.amount, total, isReverseChargeVatAccount(row.account)),
}
}
if (index === settlementIndex) {
return { account: row.account, label: label(row.account), side: row.side, type: 'settlement', ratio: 1 }
}
return {
account: row.account,
label: label(row.account),
side: row.side,
type: 'business',
ratio: Math.round((row.amount / total) * 10000) / 10000,
}
})
}