3ff12faa77
* 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>
305 lines
12 KiB
TypeScript
305 lines
12 KiB
TypeScript
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,
|
||
}
|
||
})
|
||
}
|