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>
This commit is contained in:
Mattsson
2026-08-10 13:59:55 +02:00
committed by GitHub
parent 38f5d9812e
commit 3ff12faa77
6 changed files with 114 additions and 22 deletions
+1
View File
@@ -850,4 +850,5 @@ One line per decision: `[YYYY-MM-DD] <decision>: <why>`. Appended by agents and
[2026-08-10] Transactions inbox fetches ALL pending rows merged into the single transactions state array (tracked window boundary via pagedCountRef/pagedThroughDate) instead of a parallel pendingTransactions state: ~20 setTransactions mutation call sites (book/ignore/edit/delete) would each need dual updates and would drift; the merged array keeps mutations one code path, at the cost of a date-boundary filter for the history view.
[2026-08-09] /migrate streaming is opt-in via Accept: application/x-ndjson instead of replacing the JSON contract: the wizard is the only caller today but a hard cutover would break open pre-deploy tabs and the route's locked error-status tests; mid-stream failures re-send the structured envelope as a terminal error event because the 200 is already committed once the stream opens.
[2026-08-09] Regeluppdat + docs-freshness scans (#1417) built as local loop skills with due-date self-gating, not cloud crons: cloud routines were retired 2026-07-20, and session crons die at 7 days, so weekly/monthly cadence is achieved by loop-ignite running each loop when its run marker says it is due. loop-regeluppdat files tickets only (no auto-fix PRs): regulatory changes touch money math and compliance logic, which .claude/loops.md forbids loops from changing. Docs check diffs the live .md mirror routes against repo-built markdown (exact, canonicalised both sides) instead of diffing the gnubok-website checkout, so it also catches deployed-but-stale and route-404 states.
[2026-08-10] RC VAT template fix scoped to template-library.ts (applyTemplate + deriveTemplateLinesFromBooking): counterparty/SIE learned patterns already strip fiktiv-moms legs and regenerate them via generateReverseChargeLines with the gross base, so mapping-engine/counterparty paths were already correct and stay untouched.
[2026-08-10] Receipt hunt cron keeps searchMail=false: the mailbox leg stays manual until its time budget is proven. A sweep of one 172-message mailbox took over 600s while the route's maxDuration is 300, so enabling it nightly would time out mid-run. Flip both this flag and RECEIPT_HUNT_COMPANY_IDS together once the per-company budget is measured.
@@ -50,18 +50,72 @@ describe('applyTemplate', () => {
{ account: '2645', label: 'Ingående moms', side: 'debit', type: 'vat', vat_rate: 0.25 },
{ account: '1930', label: 'Företagskonto', side: 'credit', type: 'settlement', ratio: 1.0 },
]
// Total payment is 10000 SEK (no VAT on the payment itself for reverse charge)
// Total payment is 10000 SEK. The supplier charged no VAT, so the payment
// IS the beskattningsunderlag: fiktiv moms = 10000 * 0.25 on top, never
// extracted out of the total.
const result = applyTemplate(lines, 10000)
expect(result).toHaveLength(4)
// Business line = 10000 * 1.0
expect(result[0].debit_amount).toBe('10000.00')
// VAT = 10000 * 0.25 / (1 + 0.25) = 2000
expect(result[1].credit_amount).toBe('2000.00')
expect(result[2].debit_amount).toBe('2000.00')
// VAT = 10000 * 0.25 = 2500 on both offsetting legs
expect(result[1].credit_amount).toBe('2500.00')
expect(result[2].debit_amount).toBe('2500.00')
// Settlement = 10000
expect(result[3].credit_amount).toBe('10000.00')
})
it('reverse charge regression: 807.99 gives 202.00, not 161.60 (20%)', () => {
// User-reported bug: the seeded "Inköp EU-tjänster, omvänd moms 25%"
// template produced 807.99 * 0.25 / 1.25 = 161.60 (the inclusive
// back-calculation, i.e. 20% of the amount) instead of 807.99 * 0.25.
const lines: BookingTemplateLibraryLine[] = [
{ account: '6540', label: 'IT-tjänster', side: 'debit', type: 'business', ratio: 1.0 },
{ account: '2614', label: 'Utgående moms omvänd skattskyldighet 25%', side: 'credit', type: 'vat', vat_rate: 0.25 },
{ account: '2645', label: 'Beräknad ingående moms 25%', side: 'debit', type: 'vat', vat_rate: 0.25 },
{ account: '1930', label: 'Företagskonto', side: 'credit', type: 'settlement', ratio: 1.0 },
]
const result = applyTemplate(lines, 807.99)
const byAccount = Object.fromEntries(result.map((l) => [l.account_number, l]))
expect(byAccount['6540'].debit_amount).toBe('807.99')
expect(byAccount['2614'].credit_amount).toBe('202.00')
expect(byAccount['2645'].debit_amount).toBe('202.00')
expect(byAccount['1930'].credit_amount).toBe('807.99')
// The entry balances: the fiktiv legs net to zero.
const sumDebit = result.reduce((s, l) => s + (parseFloat(l.debit_amount || '0') || 0), 0)
const sumCredit = result.reduce((s, l) => s + (parseFloat(l.credit_amount || '0') || 0), 0)
expect(sumDebit).toBeCloseTo(sumCredit, 2)
})
it('applies reduced-rate reverse charge (2624/2645 at 12%) on top of the base', () => {
const lines: BookingTemplateLibraryLine[] = [
{ account: '4010', label: 'Varuinköp', side: 'debit', type: 'business', ratio: 1.0 },
{ account: '2624', label: 'Utgående moms omvänd 12%', side: 'credit', type: 'vat', vat_rate: 0.12 },
{ account: '2645', label: 'Beräknad ingående moms 12%', side: 'debit', type: 'vat', vat_rate: 0.12 },
{ account: '1930', label: 'Företagskonto', side: 'credit', type: 'settlement', ratio: 1.0 },
]
const result = applyTemplate(lines, 1000)
expect(result[1].credit_amount).toBe('120.00')
expect(result[2].debit_amount).toBe('120.00')
})
it('save-as-mall then apply round-trips a correct reverse-charge booking', () => {
// A correctly booked RC purchase saved via "Spara som mall" and re-applied
// must reproduce the 25%-of-base fiktiv moms, not shrink it to 20%.
const source = [
{ account_number: '6540', debit_amount: '807.99', credit_amount: '' },
{ account_number: '2645', debit_amount: '202.00', credit_amount: '' },
{ account_number: '2614', debit_amount: '', credit_amount: '202.00' },
{ account_number: '1930', debit_amount: '', credit_amount: '807.99' },
]
const derived = deriveTemplateLinesFromBooking(source)
expect(derived.find((l) => l.account === '2614')!.vat_rate).toBe(0.25)
const applied = applyTemplate(derived, 807.99)
const byAccount = Object.fromEntries(applied.map((l) => [l.account_number, l]))
expect(byAccount['2614'].credit_amount).toBe('202.00')
expect(byAccount['2645'].debit_amount).toBe('202.00')
expect(byAccount['6540'].debit_amount).toBe('807.99')
})
it('handles representation with 25% input VAT', () => {
const lines: BookingTemplateLibraryLine[] = [
{ account: '6072', label: 'Representation', side: 'debit', type: 'business', ratio: 1.0 },
+32 -15
View File
@@ -1,6 +1,7 @@
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'
/**
@@ -30,9 +31,16 @@ export const TEMPLATE_CATEGORY_LABELS: Record<BookingTemplateCategory, string> =
* ready for the JournalEntryForm.
*
* The algorithm:
* 1. VAT lines: amount = totalAmount × vat_rate / (1 + vat_rate)
* 2. Settlement lines: amount = totalAmount (the full payment)
* 3. Business lines: amount = totalAmount × ratio (cost/revenue net of VAT handled separately)
* 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.
@@ -47,8 +55,11 @@ export function applyTemplate(
let amount = 0
if (line.type === 'vat' && line.vat_rate) {
// VAT calculated on the total inclusive amount
amount = Math.round(totalAmount * line.vat_rate / (1 + line.vat_rate) * 100) / 100
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 {
@@ -185,13 +196,15 @@ export interface BookingRowInput {
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. The implied rate
* is vatAmount / net where net = total vatAmount (the same relationship
* applyTemplate inverts: vat = total × rate / (1 + rate)).
* 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): number {
const net = total - vatAmount
const implied = net > 0 ? vatAmount / net : 0
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,
@@ -204,7 +217,10 @@ function snapVatRate(vatAmount: number, total: number): number {
*
* 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)
* - 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
@@ -235,8 +251,9 @@ export function deriveTemplateLinesFromBooking(
if (parsed.length < 2) return []
const sumDebit = parsed.reduce((s, r) => (r.side === 'debit' ? s + r.amount : s), 0)
const sumCredit = parsed.reduce((s, r) => (r.side === 'credit' ? s + r.amount : s), 0)
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 []
@@ -270,7 +287,7 @@ export function deriveTemplateLinesFromBooking(
label: label(row.account),
side: row.side,
type: 'vat',
vat_rate: snapVatRate(row.amount, total),
vat_rate: snapVatRate(row.amount, total, isReverseChargeVatAccount(row.account)),
}
}
if (index === settlementIndex) {
+18
View File
@@ -62,6 +62,24 @@ export function isReverseChargeBasisAccount(account: string): boolean {
return RC_BASIS_ACCOUNTS.has(account)
}
/**
* Fiktiv-moms VAT accounts: self-assessed output VAT for reverse charge
* (2614/2624/2634) and import (2615/2625/2635), plus the offsetting calculated
* input legs (2645 EU/non-EU, 2647 domestic RC). The foreign supplier charged
* no VAT, so the transaction total IS the tax base (beskattningsunderlag), and
* the amount booked on these accounts is the self-assessed VAT: total * rate
* added on top, never rate/(1+rate) extracted out of the total. Mirrors the
* private set in counterparty-templates.ts used to keep these legs out of
* learned patterns.
*/
export const REVERSE_CHARGE_VAT_ACCOUNTS: ReadonlySet<string> = new Set([
'2614', '2624', '2634', '2615', '2625', '2635', '2645', '2647',
])
export function isReverseChargeVatAccount(account: string): boolean {
return REVERSE_CHARGE_VAT_ACCOUNTS.has(account)
}
/**
* The self-assessed VAT rate to apply to a reverse-charge line.
*
+4 -2
View File
@@ -27,8 +27,10 @@ import { accountNumberSchema } from '@/lib/invariants/zod'
* `applyTemplate()` in `lib/bookkeeping/template-library.ts` turns a total
* amount into lines, and the three types are not decorative:
*
* - `vat`: amount is `total * vat_rate / (1 + vat_rate)`, so it carries
* `vat_rate` and never `ratio`.
* - `vat`: carries `vat_rate` and never `ratio`. Amount is
* `total * vat_rate / (1 + vat_rate)`, except on fiktiv-moms accounts
* (reverse charge/import, e.g. 2614/2645) where the total is the tax base
* and the amount is `total * vat_rate` on top.
* - `business` and `settlement`: amount is `total * ratio`, so they carry
* `ratio` and never `vat_rate`.
*
+1 -1
View File
@@ -45,7 +45,7 @@ derived from it (`applyTemplate()` in `lib/bookkeeping/template-library.ts`):
| Type | Amount | Carries |
|---|---|---|
| `vat` | `total * vat_rate / (1 + vat_rate)` | `vat_rate`, never `ratio` |
| `vat` | `total * vat_rate / (1 + vat_rate)`; on fiktiv-moms accounts (reverse charge/import, e.g. 2614/2645) `total * vat_rate` on top of the base | `vat_rate`, never `ratio` |
| `business` | `total * ratio` | `ratio`, never `vat_rate` |
| `settlement` | `total * ratio` | `ratio`, never `vat_rate` |