diff --git a/DECISIONS.md b/DECISIONS.md index 8c3b52f4..5545fb4d 100644 --- a/DECISIONS.md +++ b/DECISIONS.md @@ -850,4 +850,5 @@ One line per decision: `[YYYY-MM-DD] : `. 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. diff --git a/lib/bookkeeping/__tests__/template-library.test.ts b/lib/bookkeeping/__tests__/template-library.test.ts index a40b0433..65ddc83d 100644 --- a/lib/bookkeeping/__tests__/template-library.test.ts +++ b/lib/bookkeeping/__tests__/template-library.test.ts @@ -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 }, diff --git a/lib/bookkeeping/template-library.ts b/lib/bookkeeping/template-library.ts index 25f35cea..d050bdf5 100644 --- a/lib/bookkeeping/template-library.ts +++ b/lib/bookkeeping/template-library.ts @@ -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 = * 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( (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) { diff --git a/lib/bookkeeping/vat-entries.ts b/lib/bookkeeping/vat-entries.ts index c981febe..39e89346 100644 --- a/lib/bookkeeping/vat-entries.ts +++ b/lib/bookkeeping/vat-entries.ts @@ -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 = 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. * diff --git a/lib/packs/schema.ts b/lib/packs/schema.ts index f0db6389..d842e96b 100644 --- a/lib/packs/schema.ts +++ b/lib/packs/schema.ts @@ -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`. * diff --git a/packs/README.md b/packs/README.md index 83214f90..c6490951 100644 --- a/packs/README.md +++ b/packs/README.md @@ -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` |