Odeva № 026 Resources · article
Tourist Tax · Open Source · Conformance Testing
Published · 5 May 2026

§ Essay · Odeva

Building a Tax Conformance Kit

Probably the nerdiest solution to a task that involves manual labor.

Published 5 May 2026
Tourist TaxOpen SourceConformance TestingTax Automation

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

Government and source references:

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