In the Netherlands (and many other countries), parks are required to submit information about “tourist tax”. The broad definition is simple:
A tourist tax is any form of tax aimed at generating revenue from tourists or the tourism industry. Source: Wikipedia: Tourist tax / hotel tax
For parks, this usually means the hotel tax / occupancy tax version: a tax attached to accommodation, collected because someone rents a room, home, pitch, or other place to stay.
The issue with this is that there are many rules that come with this definition. A lot of these are on municipality-level. For example, some municipalities do not include tourist tax when you’re making a reservation in the same municipality.
But there are more rules. These have to do with the type of park you own (glamping, camping spots, bnb). And this is only for the Netherlands. For example, in Spain it is on the region / autonomous community level. If you want to do these calculations cross-country, you’re going to be doing a lot of manual labour.
Existing solutions
There are existing solutions available, but these are usually behind closed doors (so validation/debugging is hard), and the pricing is not something I’d be comfortable with to pull in.
At Odeva, we believe open-source is the most respectful way to build software. Let people use your code however they please. Do you want to pull in the logic and add a country? Perhaps you’re figuring out why the tax is like that? Feel free!
Starting point
For these kind of calculations, repeatable, explainable, source-backed, and testable logic is the way to go. It’s a good thing that Dutch municipalities all follow roughly the same format. Pulling in this data is very easy.
Tax calculations always fall in two groups.
- A fixed amount (1 adult = €0.20,-, 1 child = €0.05,-)
- A percentage (
20.00 * 0.08 = €1.60,-)
If we take Amsterdam as an example, you can get the following logic represented in JSON:
{
"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"
}
A rule is data
The core of the kit is a data model for rulesets. A ruleset has a jurisdiction and a list of rules. Each rule has dates, scope, a calculation kind, optional predicates, optional exemptions, and source metadata.
The simplified shape 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"
}
]
}
The calculation.kind is deliberately namespaced. generic.per_person_per_night is not the same thing as generic.percentage_of_base, and neither is the same thing as a Dutch forfait or a tiered camping arrangement.
The registry currently includes calculation kinds such as:
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
It also includes predicates and exemptions, for example:
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
An example
Breda is a useful example because it shows how quickly the model expands.
One rule in the kit represents the general per-person-per-night rate for hotels, apartments, bungalows, and short-stay accommodation:
{
"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"
}
}
That still looks manageable.
But camping has more dimensions. Did the operator provide the accommodation, or did the guest bring it? Is the operator using a per-night price or an arrangement price? Are we applying a flat per-person-per-night amount, or a tier based on stay duration?
One camping arrangement rule looks like this:
{
"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"
}
}
The calculation is hard because the rule has to be selected correctly before multiplication happens. The conformance cases are where this becomes practical. The kit has explicit test cases for boundaries like 29 nights, 30 nights, 119 nights, and 120 nights. Those are the cases where a human implementation usually drifts from the rule text.
Reservation Info
Apart from these rulesets, we also need to know the following:
where are the properties? (one reservation might have multiple properties)
when does the stay happen?
what kind of accommodation is it?
who is staying there?
where does the guest live?
does a local exemption apply?
is this a booking-level rule or an assessment-level rule?
which source publication says so?
Luckily we already know all of this when we’re using a reservation system.
Cross-country rules
This is also why the model cannot assume that every rule is attached to a municipality.
The Netherlands is municipality-first: the municipality decides whether to levy tourist tax and how the rate works. Spain is different. There is no single national tourist-tax setup, and the taxes we care about are usually defined at the autonomous community / regional level.
Catalonia is a good example. The IEET is a Catalan tax, administered by the Catalan Tax Agency. Barcelona then matters as a locality because Barcelona has its own surcharge and rate treatment. The Balearic Islands are similar in the sense that the tourist-stay tax is an autonomous-community tax, not a municipal one.
That is why the data model has jurisdiction and location_scope instead of only municipality_code. A Dutch rule can hang directly off a municipality. A Catalonia rule can hang off ES-CT, and only narrow to Barcelona when the source text actually does that.
Bonus: Updating sources
Another thing that we need to account for is that tourist taxes will be updated. That means the previous entry will get an end date, and a new entry will pop up with new information.
The kit has a scheduled Forgejo workflow called Refresh Generated Data. Every day it:
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
The source list is public:
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
You can check the source code for a commit that was automatically deployed:
c132d3d994c804f963466aa239c10a303d90f629
Refresh generated tax data
AuthorDate: 2026-05-05 04:18:29 UTC
That commit updated Haarlem data. It added a new 2026-05-06 ruleset, changed the previous Haarlem fixture so it ended on 2026-05-05, and bumped the Ruby package version from 0.2.1 to 0.2.2.
This part needs a careful caveat: generated legal data is not automatically final legal truth. The generated Haarlem fixture is marked scraped and draft. That is intentional. The pipeline is a source-backed way to detect and draft changes. We still manually curate entries. I like that boundary, because these entries aren’t perfect, and neither is code.
Sources
- GitHub mirror: https://github.com/Odeva-Labs/tax-conformance-kit
- Ruby package: https://rubygems.org/gems/tax_conformance_kit
Government and source references:
- https://en.wikipedia.org/wiki/Tourist_tax#Hotel_tax
- https://business.gov.nl/regulations/tourist-tax/
- https://www.rijksoverheid.nl/onderwerpen/gemeenten/vraag-en-antwoord/wat-is-toeristenbelasting-en-wanneer-moet-ik-dit-betalen
- https://zoekdienst.overheid.nl/sru/Search?x-connection=cvdr
- https://lokaleregelgeving.overheid.nl/
- https://datasets.cbs.nl/CSV/CBS/nl/86247NED
- https://atc.gencat.cat/es/tributs/ieet/quota-tributaria
Want a system built for flexibility?
Join our waitlist to learn more about how Odeva is building the future of vacation rental management.
Join Waitlist→