fix(packs): representation template named itself avdragsgill while booking 6072 (#1396)

Raised by the Swedish compliance bot on #1395. The template called itself
"Representation (avdragsgill, 25% moms)" and labelled its cost line
"Representation avdragsgill", but booked to 6072, which BAS 2026 defines as
Representation, EJ avdragsgill. The swedish-vat skill lists that exact
confusion under Representation errors: "Not separating avdragsgill/ej
avdragsgill on correct accounts (6071 vs 6072)".

The account was right and the words were wrong. Meal representation stopped
being income-tax deductible in 2017, so 6072 is where the cost belongs; only
enklare fortaring (max 60 kr per person) is still deductible, on 6071. Renamed
to "Representation, maltid (ej avdragsgill)" and relabelled to match.

VAT moves from 25% to the 12% restaurang-och-catering rate our own static
representation_external template in booking-templates.ts already used, so the
two template systems stop contradicting each other, with the paired net ratio
1/1.12. A legal_note now carries the 300 kr per person VAT cap, which this
format cannot compute because a template sees only a total and never a
participant count, plus the reminder that the rate follows the underlag rather
than the template.

Correcting the record on one point: the seeded ratio 0.8 was NOT a phantom 80%
deductibility rule, as the bot suggested and as I first repeated. 1/1.25 = 0.8,
so it was the net-of-VAT fraction pairing with the 25% rate, and the arithmetic
balanced. Only the naming and the missing cap were wrong. The ratio is now
commented in the file so the next reader does not make the same misreading.

Slug deliberately unchanged though it still reads "avdragsgill": a slug is an
identifier, not a label (packs/README.md). Renaming it would retire this
template and insert a new one, breaking every company's booking_template_usage
history.

The drift guard caught the rename, which exposed that the test joined packs to
seeded rows by NAME. That is the same fragility that made pack_slug the sync's
key, so INTENTIONAL_DIVERGENCES now records each entry's seededName explicitly.

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Jakob Wennberg
2026-08-03 22:32:12 +02:00
committed by GitHub
parent 267ed6c1bb
commit a3d8fe733c
2 changed files with 60 additions and 20 deletions
+34 -14
View File
@@ -23,18 +23,42 @@ import seeded from './fixtures/seeded-system-templates.json'
* Every entry is a defect found by the pack validator and fixed with a domain
* source cited in the commit.
*/
const INTENTIONAL_DIVERGENCES: Record<string, string> = {
loneutbetalning:
'Seeded version debited 2710 @0.3 + 2920 @0.12 + 7010 @1.0 against a single 1.0 credit, ' +
interface Divergence {
/**
* The name this template carried in the seed. Needed because a fix may rename
* it, and the seeded fixture predates pack_slug so name is the only join key
* available there. Exactly the fragility that made pack_slug the sync's key.
*/
seededName: string
reason: string
}
const INTENTIONAL_DIVERGENCES: Record<string, Divergence> = {
loneutbetalning: {
seededName: 'Löneutbetalning',
reason:
'Seeded version debited 2710 @0.3 + 2920 @0.12 + 7010 @1.0 against a single 1.0 credit, ' +
'so it totalled 1.42x the amount and could never post. Rebuilt per the swedish-payroll ' +
'skill: Debit 7010 gross, Credit 2710 tax, Credit 1930 net. The 2920 semesterlöneskuld ' +
'line moved out because vacation accrual is its own verifikat (7290/2920).',
'periodiseringsfond-avsattning-ab':
'Seeded version used account 2113, i.e. the fund for tax year 2013 under the pre-2020 ' +
},
'periodiseringsfond-avsattning-ab': {
seededName: 'Periodiseringsfond avsättning (AB)',
reason:
'Seeded version used account 2113, i.e. the fund for tax year 2013 under the pre-2020 ' +
'year-tagged block. Those funds had to be reversed years ago and the account is not in ' +
'BAS 2026, so the template could not resolve. Now uses 2110 Periodiseringsfonder, which ' +
'does not rot annually; the legal_note points at the year-tagged 2120-2129 alternative.',
'periodiseringsfond-aterforing-ab': 'Same 2113 fix as periodiseringsfond-avsattning-ab.',
},
'periodiseringsfond-aterforing-ab': {
seededName: 'Periodiseringsfond återföring (AB)',
reason: 'Same 2113 fix as periodiseringsfond-avsattning-ab.',
},
'representation-avdragsgill-25-moms': {
seededName: 'Representation (avdragsgill, 25% moms)',
reason:
'Seeded version booked to 6072 (Representation, EJ avdragsgill) while naming and labelling itself "avdragsgill", which is the exact confusion the swedish-vat skill lists under Representation errors. The account was right and the words were wrong: meal representation stopped being income-tax deductible in 2017. Renamed to match, VAT moved from 25% to the 12% restaurang rate our own static representation_external template already used (with the net ratio 1/1.12 that pairs with it), and a legal_note added for the 300 kr per person VAT cap, which this format cannot compute because it has no participant count. Slug deliberately unchanged: it is an identifier, not a label.',
},
}
interface SeededTemplate {
@@ -76,11 +100,7 @@ describe('pack catalogue is a lossless port of the seeded system templates', ()
it('reproduces the seeded templates exactly, except where we deliberately fixed one', () => {
const unchanged = packs.filter((p) => !(p.pack.meta.slug in INTENTIONAL_DIVERGENCES))
const changedNames = new Set(
packs
.filter((p) => p.pack.meta.slug in INTENTIONAL_DIVERGENCES)
.map((p) => p.pack.meta.name),
)
const changedNames = new Set(Object.values(INTENTIONAL_DIVERGENCES).map((d) => d.seededName))
const fromPacks = unchanged.map((p) => canonical(packToLibraryRow(p.pack))).sort()
const fromDb = (seeded as SeededTemplate[])
@@ -95,11 +115,11 @@ describe('pack catalogue is a lossless port of the seeded system templates', ()
it('every declared divergence actually diverges, so the list cannot go stale', () => {
const byName = new Map((seeded as SeededTemplate[]).map((t) => [t.name, canonical(t)]))
for (const slug of Object.keys(INTENTIONAL_DIVERGENCES)) {
for (const [slug, divergence] of Object.entries(INTENTIONAL_DIVERGENCES)) {
const pack = packs.find((p) => p.pack.meta.slug === slug)
expect(pack, `${slug} is in INTENTIONAL_DIVERGENCES but no such pack exists`).toBeDefined()
const seededForm = byName.get(pack!.pack.meta.name)
expect(seededForm, `no seeded template named "${pack!.pack.meta.name}"`).toBeDefined()
const seededForm = byName.get(divergence.seededName)
expect(seededForm, `no seeded template named "${divergence.seededName}"`).toBeDefined()
expect(
canonical(packToLibraryRow(pack!.pack)),
`${slug} is listed as diverging but matches the seed: remove its entry`,
+26 -6
View File
@@ -1,24 +1,44 @@
# Konteringspaket. Schema: lib/packs/schema.ts
# Validated by `npm run validate:packs` (CI gate).
#
# The slug still says "avdragsgill". That is deliberate: a slug is an
# identifier, not a label (packs/README.md). Renaming it would retire this
# template and insert a new one, breaking every company's booking_template_usage
# history for it. The NAME is what users read, and that is now correct.
meta:
slug: representation-avdragsgill-25-moms
order: 13
name: 'Representation (avdragsgill, 25% moms)'
name: 'Representation, måltid (ej avdragsgill)'
category: representation
entity_type: all
description: >-
Extern representation med avdragsgill moms. Max 300 kr/person exkl. moms.
Extern representation, måltid. Kostnaden är inte avdragsgill vid
inkomstbeskattningen; momsen är avdragsgill upp till ett tak per person.
legal_note: >-
Avdraget för måltidsrepresentation vid inkomstbeskattningen slopades 2017,
därför bokas hela kostnaden på 6072 Representation, ej avdragsgill.
Momsen är avdragsgill på ett underlag om högst 300 kr exkl. moms per person
och tillfälle. Mallen känner inte antalet deltagare och kan därför inte
räkna ut taket: kontrollera momsraden manuellt mot antalet personer, och
använd schablonbeloppet när måltiden innehåller både mat och alkohol.
Enklare förtäring (max 60 kr per person) är en annan situation och bokas
på 6071 Representation, avdragsgill. Momssatsen 12 % avser restaurang-
och cateringtjänster; kontrollera satsen mot underlaget, den beror på vad
som köpts och inte på mallen.
# ratio 0.892857 = 1/1.12: the cost line is the amount NET of the VAT that the
# vat line extracts from the same VAT-inclusive total. It is arithmetic tied to
# vat_rate, not a deductibility percentage. Change one and you must change both.
lines:
- account: '6072'
label: 'Representation avdragsgill'
label: 'Representation, ej avdragsgill'
side: debit
type: business
ratio: 0.8
ratio: 0.892857
- account: '2641'
label: 'Ingående moms'
label: 'Ingående moms (justera mot taket)'
side: debit
type: vat
vat_rate: 0.25
vat_rate: 0.12
- account: '1930'
label: 'Företagskonto'
side: credit