Odeva № 026 Resources · artikel
Toeristenbelasting · Open Source · Conformance Testing
Gepubliceerd · 5 mei 2026

§ Essay · Odeva

Een Tax Conformance Kit bouwen

Waarschijnlijk de meest nerdy oplossing voor een taak die normaal handwerk is.

Gepubliceerd 5 mei 2026
ToeristenbelastingOpen SourceConformance TestingTax Automation

In Nederland (en veel andere landen) moeten parken informatie aanleveren over “toeristenbelasting”. De kern is simpel:

Wie in de gemeente overnacht waar je bent ingeschreven in de basisadministratie, hoeft deze belasting niet te betalen. Bron: Wikipedia: Toeristenbelasting

Artikel 224 Gemeentewet is de juridische basis: gemeenten mogen toeristenbelasting heffen voor verblijf binnen de gemeente door mensen die daar niet als ingezetene staan ingeschreven.

Het probleem hiermee is dat er veel regels bij deze definitie komen kijken. Veel hiervan gebeurt op gemeenteniveau. Sommige gemeenten rekenen bijvoorbeeld geen toeristenbelasting wanneer je een reservering maakt in dezelfde gemeente.

Maar er zijn meer regels. Die hebben te maken met het type park dat je hebt (glamping, campingplekken, bnb). En dit is alleen nog maar voor Nederland. In Spanje zit het bijvoorbeeld op regio- / autonome-gemeenschapsniveau. Als je deze berekeningen cross-country wilt doen, ben je veel handwerk aan het doen.

Bestaande oplossingen

Er bestaan oplossingen, maar die zitten meestal achter gesloten deuren (waardoor validatie/debugging lastig is), en de pricing is niet iets waar ik me comfortabel bij voel om zomaar binnen te halen.

Bij Odeva geloven we dat open-source de meest respectvolle manier is om software te bouwen. Laat mensen je code gebruiken zoals ze willen. Wil je de logica binnenhalen en een land toevoegen? Ben je misschien aan het uitzoeken waarom de belasting zo werkt? Ga je gang!

Startpunt

Voor dit soort berekeningen is herhaalbare, uitlegbare, source-backed en testbare logica de juiste manier. Het is handig dat Nederlandse gemeenten allemaal ongeveer hetzelfde format volgen. Deze data binnenhalen is heel makkelijk.

Belastingberekeningen vallen altijd in twee groepen.

  • Een vast bedrag (1 volwassene = €0,20,-, 1 kind = €0,05,-)
  • Een percentage (20.00 * 0.08 = €1.60,-)

Als we Amsterdam als voorbeeld nemen, kun je de volgende logica in JSON weergeven:

{
  "id": "nl-amsterdam-2026-general",
  "municipality_code": "0363",
  "municipality_name": "Amsterdam",
  "valid_from": "2026-01-01",
  "calculation": {
    "kind": "generic.percentage_of_base",
    "params": {
      "rate_pct": 7.0,
      "base": "accommodation_fee_exclusive_of_tax"
    },
    "currency": "EUR"
  },
  "source": {
    "source_url": "https://lokaleregelgeving.overheid.nl/CVDR750921",
    "cvdr_id": "CVDR750921"
  },
  "confidence": "scraped"
}

Een regel is data

De kern van de kit is een datamodel voor rulesets. Een ruleset heeft een jurisdiction en een lijst met regels. Elke regel heeft datums, scope, een calculation kind, optionele predicates, optionele exemptions en source metadata.

De versimpelde vorm is:

{
  "id": "nl-example-2026",
  "domain": "tourist_tax",
  "jurisdiction": {
    "country_code": "NL",
    "country_name": "Netherlands"
  },
  "rules": [
    {
      "id": "example-rule",
      "municipality_code": "0000",
      "municipality_name": "Example",
      "valid_from": "2026-01-01",
      "valid_to": null,
      "applies_to": {
        "accommodation_types": ["hotel"]
      },
      "calculation": {
        "kind": "generic.per_person_per_night",
        "params": {
          "amount": 3.95
        },
        "currency": "EUR"
      },
      "predicates": [],
      "exemptions": [
        {
          "kind": "guest.resident_of_same_municipality"
        }
      ],
      "source": {
        "source_url": "https://lokaleregelgeving.overheid.nl/...",
        "cvdr_id": "CVDR..."
      },
      "confidence": "scraped"
    }
  ]
}

De calculation.kind is expres namespaced. generic.per_person_per_night is niet hetzelfde als generic.percentage_of_base, en geen van beide is hetzelfde als een Nederlandse forfait of een tiered camping arrangement.

De registry bevat op dit moment calculation kinds zoals:

generic.per_night
generic.per_person_per_night
generic.per_person_per_night_discount_after_nights
generic.fixed_amount
generic.percentage_of_base
nl.tiered_by_stay_duration
nl.fixed_per_pitch_per_year
nl.forfait_per_person_per_night

Hij bevat ook predicates en exemptions, bijvoorbeeld:

guest.resident_of_same_municipality
guest.age_below
stay.wtza_care_institution
stay.coa_asylum_housing
stay.accommodation_brought_by
stay.pricing_arrangement
stay.supervised_minor_group
stay.seasonal_window
cross_tax.already_subject_to

Een voorbeeld

Breda is een handig voorbeeld omdat het laat zien hoe snel het model uitbreidt.

Een regel in de kit vertegenwoordigt het algemene tarief per persoon per nacht voor hotels, appartementen, bungalows en short-stay accommodatie:

{
  "id": "nl-breda-2026-general-ppn",
  "municipality_code": "0758",
  "municipality_name": "Breda",
  "valid_from": "2026-01-01",
  "valid_to": "2026-12-31",
  "applies_to": {
    "accommodation_types": ["hotel", "apartment", "bungalow", "short_stay"]
  },
  "calculation": {
    "kind": "generic.per_person_per_night",
    "params": {
      "amount": 3.95
    },
    "currency": "EUR"
  }
}

Dat ziet er nog beheersbaar uit.

Maar camping heeft meer dimensies. Biedt de operator de accommodatie aan, of neemt de gast die zelf mee? Gebruikt de operator een prijs per nacht of een arrangementprijs? Passen we een vast bedrag per persoon per nacht toe, of een tier op basis van verblijfsduur?

Een camping-arrangementregel ziet er zo uit:

{
  "id": "nl-breda-2026-camping-operator-tiered",
  "municipality_code": "0758",
  "municipality_name": "Breda",
  "valid_from": "2026-01-01",
  "valid_to": "2026-12-31",
  "applies_to": {
    "accommodation_types": ["camping"]
  },
  "predicates": [
    {
      "kind": "stay.accommodation_brought_by",
      "params": {
        "value": "operator"
      }
    },
    {
      "kind": "stay.pricing_arrangement",
      "params": {
        "value": "arrangement"
      }
    }
  ],
  "calculation": {
    "kind": "nl.tiered_by_stay_duration",
    "params": {
      "tiers": [
        { "max_nights": 30, "amount": 45.00 },
        { "min_nights": 30, "max_nights": 120, "amount": 80.00 },
        { "min_nights": 120, "max_nights": 240, "amount": 120.00 },
        { "min_nights": 240, "max_nights": 360, "amount": 150.00 }
      ]
    },
    "currency": "EUR"
  }
}

De berekening is moeilijk omdat de juiste regel geselecteerd moet worden voordat de vermenigvuldiging gebeurt. De conformance cases zijn waar dit praktisch wordt. De kit heeft expliciete testcases voor grenzen zoals 29 nachten, 30 nachten, 119 nachten en 120 nachten. Dat zijn de gevallen waar een menselijke implementatie meestal afwijkt van de regeltekst.

Reserveringsinformatie

Naast deze rulesets moeten we ook het volgende weten:

waar zijn de accommodaties? (een reservering kan meerdere accommodaties hebben)
wanneer vindt het verblijf plaats?
wat voor type accommodatie is het?
wie verblijft er?
waar woont de gast?
geldt er een lokale vrijstelling?
is dit een boekingsregel of een regel op aanslagniveau?
welke bronpublicatie zegt dit?

Gelukkig weten we dit allemaal al wanneer we een reserveringssysteem gebruiken.

Cross-country regels

Dit is ook waarom het model niet kan aannemen dat elke regel aan een gemeente hangt.

Nederland is municipality-first: de gemeente bepaalt of er toeristenbelasting wordt geheven en hoe het tarief werkt. Spanje is anders. Er is geen enkele nationale tourist-tax setup, en de belastingen waar wij naar kijken zijn meestal op autonome-gemeenschaps- / regioniveau gedefinieerd.

Catalonië is een goed voorbeeld. De IEET is een Catalaanse belasting, beheerd door de Catalan Tax Agency. Barcelona is daarna relevant als locality omdat Barcelona een eigen surcharge en rate treatment heeft. De Balearen zijn vergelijkbaar in de zin dat de tourist-stay tax een autonome-gemeenschapsbelasting is, geen gemeentelijke belasting.

Daarom heeft het datamodel jurisdiction en location_scope in plaats van alleen municipality_code. Een Nederlandse regel kan direct aan een gemeente hangen. Een Catalonië-regel kan aan ES-CT hangen, en alleen versmallen naar Barcelona wanneer de brontekst dat echt doet.

Bonus: bronnen updaten

Nog iets waar we rekening mee moeten houden: toeristenbelastingen worden bijgewerkt. Dat betekent dat de vorige entry een einddatum krijgt, en dat er een nieuwe entry verschijnt met nieuwe informatie.

De kit heeft een geplande Forgejo workflow met de naam Refresh Generated Data. Elke dag doet die:

downloads the CBS municipality dataset
imports and backfills municipality codes
harvests recent CVDR publications
selects relevant publications
extracts candidate data
analyzes it
generates draft fixtures
commits and tags a Ruby patch release if data changed

De bronnenlijst is openbaar:

CVDR SRU search API:
https://zoekdienst.overheid.nl/sru/Search?x-connection=cvdr

Official regulation publications:
https://lokaleregelgeving.overheid.nl/CVDR...

CBS municipality dataset 86247NED:
https://datasets.cbs.nl/CSV/CBS/nl/86247NED

Je kunt de source-code bekijken voor een commit die automatisch gedeployed is:

c132d3d994c804f963466aa239c10a303d90f629
Refresh generated tax data
AuthorDate: 2026-05-05 04:18:29 UTC

Die commit werkte Haarlem-data bij. Hij voegde een nieuwe 2026-05-06 ruleset toe, paste de vorige Haarlem-fixture aan zodat die eindigde op 2026-05-05, en bumpte de Ruby package-versie van 0.2.1 naar 0.2.2.

Dit deel heeft een zorgvuldige caveat nodig: gegenereerde juridische data is niet automatisch finale juridische waarheid. De gegenereerde Haarlem-fixture is gemarkeerd als scraped en draft. Dat is expres. De pipeline is een source-backed manier om wijzigingen te detecteren en te draften. We cureren entries nog steeds handmatig. Ik vind die grens goed, omdat deze entries niet perfect zijn, en code ook niet.

Bronnen

Overheids- en bronreferenties:

Gebouwd voor de praktijk.

Probeer de demo en ontdek hoe Odeva de toekomst van vakantieverhuur beheer vormgeeft.

Meld je aan