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:
@@ -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 },
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
@@ -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
@@ -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` |
|
||||
|
||||
|
||||
Reference in New Issue
Block a user