Skip to content
Octroi

Quickstart

Load a signed Octroi release, quote three nights at a 4-star hotel in Barcelona for two adults and a child, and read the lines, totals and evidence.

This page loads a release from disk, quotes one stay and reads the answer. It takes a few minutes and needs no network once you have the files.

You need:

  • a release directory, as Tokens and HTTP delivery fetches it: manifest.json, manifest.json.sig and countries/es.json at least;
  • the calculator, which ships with the licence. The samples import it from ./calculator.

Load a release

The calculator works on a loaded release: the manifest's release id, terms and currencies, and each country's law. A country file holds the law plus the release id and terms; keep the four law fields.

import { readFile } from "node:fs/promises";
import {
  countryFileSchema,
  type LoadedRelease,
  manifestSchema,
} from "./calculator";

const readJson = async (path: string): Promise<unknown> =>
  JSON.parse(await readFile(path, "utf8"));

/** The countries your token is licensed for, from a release directory. */
export async function loadRelease(
  dir: string,
  licensed: readonly string[],
): Promise<LoadedRelease> {
  const manifest = manifestSchema.parse(await readJson(`${dir}/manifest.json`));
  const countries: LoadedRelease["countries"] = {};
  for (const { id, file } of manifest.countries) {
    if (!licensed.includes(id)) continue;
    const { country, jurisdictions, charges, evidence } =
      countryFileSchema.parse(await readJson(`${dir}/${file.path}`));
    countries[id] = { country, jurisdictions, charges, evidence };
  }
  return {
    release: manifest.release,
    meta: manifest.meta,
    currencies: manifest.currencies,
    countries,
  };
}

manifestSchema and countryFileSchema throw on a file that isn't a valid release file. Before you trust the files, verify the signature and each file's hash.

Quote a stay

Three nights at a 4-star hotel in Barcelona, from 12 to 15 November 2026, for two adults aged 41 and 38 and a child aged 9:

import { quote } from "./calculator";
import { loadRelease } from "./load";

const release = await loadRelease("./releases/2026-10-11", ["es"]);

const answer = quote(release, {
  place: { jurisdiction: "es.barcelona", type: "hotel", class: "4" },
  stay: { arrival: "2026-11-12", departure: "2026-11-15" },
  rooms: [{ guests: [{ age: 41 }, { age: 38 }, { age: 9 }] }],
});

es.barcelona is the city's jurisdiction id. You can name the place by its LAU code instead; see Resolve a place. The type and class come from the region's classification: the Catalan tourist tax's rate depends on both.

Read the answer

This is the answer, trimmed: the second line's nights, both lines' evidence lists and the terms in meta are cut short.

{
  "release": "2026-10-11",
  "calculator": "1.0.0",
  "status": "complete",
  "place": {
    "jurisdictions": ["es", "es.catalunya", "es.barcelona"],
    "type": "hotel",
    "class": "4"
  },
  "lines": [
    {
      "room": 0,
      "charge": "es.catalunya.municipal-surcharge",
      "version": "2026-04-01",
      "kind": "city_tax",
      "name": "Recàrrec municipal sobre l'impost sobre les estades en establiments turístics",
      "nameEn": "Municipal surcharge on the tourist stay tax",
      "jurisdiction": "es.catalunya",
      "currency": "EUR",
      "amountMinor": 3000,
      "vatMinor": 0,
      "nights": [
        { "date": "2026-11-12", "liable": 2, "amountMinor": 1000, "vatMinor": 0 },
        { "date": "2026-11-13", "liable": 2, "amountMinor": 1000, "vatMinor": 0 },
        { "date": "2026-11-14", "liable": 2, "amountMinor": 1000, "vatMinor": 0 }
      ],
      "inPrice": false,
      "payer": "guest",
      "collectedBy": ["operator"],
      "explanation": {
        "code": "per_person_night",
        "params": { "nights": 3, "liable": 2, "amount": "30.00 EUR", "exempt": "aged_16_or_under" },
        "text": "Municipal surcharge on the tourist stay tax: 30.00 EUR over 3 night(s), at most 2 liable a night."
      },
      "claimable": [],
      "evidence": ["ev_267460fbf469eac0", "ev_49bd7a9b78b1ae52", "ev_ac687c6c38ae608e"]
    },
    {
      "room": 0,
      "charge": "es.catalunya.tourist-tax",
      "version": "2026-04-01",
      "kind": "city_tax",
      "name": "Impost sobre les estades en establiments turístics",
      "nameEn": "Tax on stays in tourist establishments",
      "jurisdiction": "es.catalunya",
      "currency": "EUR",
      "amountMinor": 2040,
      "vatMinor": 0,
      "nights": [
        { "date": "2026-11-12", "liable": 2, "amountMinor": 680, "vatMinor": 0 }
      ],
      "inPrice": false,
      "payer": "guest",
      "collectedBy": ["operator"],
      "explanation": {
        "code": "per_person_night",
        "params": { "nights": 3, "liable": 2, "amount": "20.40 EUR", "exempt": "aged_16_or_under" },
        "text": "Tax on stays in tourist establishments: 20.40 EUR over 3 night(s), at most 2 liable a night."
      },
      "claimable": [],
      "evidence": ["ev_c873a3f06bb483bd", "ev_ac687c6c38ae608e"]
    }
  ],
  "priceDependent": [],
  "unknowns": [],
  "totals": [{ "currency": "EUR", "addedMinor": 5040, "inPriceMinor": 0 }],
  "assumptions": [],
  "meta": {
    "release": "2026-10-11",
    "license": { "id": "tdl-1.0", "url": "https://octroi.app/licence" },
    "attribution": ["LAU and NUTS codes: © European Union, Eurostat, LAU 2024 and NUTS 2024, …"],
    "disclaimer": { "id": "information-not-advice-1", "text": "Information, not tax advice. …" }
  }
}

What it says:

  • status is complete: every charge that applies was priced, and nothing is unknown.
  • Two charges apply. Catalonia's tourist tax, version 2026-04-01, is €3.40 a person a night for a 4-star hotel: 340 × 2 liable guests × 3 nights = 2040. Barcelona's municipal surcharge, version 2026-04-01, is €5.00 a person a night: 500 × 2 × 3 = 3000. Both are levied under Catalan law, so both lines carry the jurisdiction es.catalunya.
  • The child is exempt. Each night has 2 liable guests, not 3, and explanation.params.exempt names the exemption, aged_16_or_under.
  • totals adds the lines up per currency: 5040 minor units, €50.40, added on top of the room price (inPrice is false on both lines).

Money is always in integer minor units of the line's currency. The manifest's currencies gives each currency's exponent; EUR's is 2.

Show the evidence

Each line's evidence lists the ids of the sources behind its version, rates and exemptions. The country's law in the loaded release holds the sources themselves:

for (const line of answer.lines) {
  const law = release.countries[line.charge.slice(0, 2)];
  const sources = new Map(law?.evidence.map((source) => [source.id, source]));
  for (const id of line.evidence) console.log(line.charge, sources.get(id));
}

The tourist tax's rate for a 4-star hotel rests on this one:

{
  "id": "ev_c873a3f06bb483bd",
  "url": "https://www.boe.es/diario_boe/txt.php?id=BOE-A-2026-6642",
  "quote": "2. Hotel de 4 estrellas y 4 estrellas superior, y establecimiento o equipamiento de categoría equivalente. 3,40 7,00",
  "english": "2. 4-star and 4-star superior hotel, and equivalent establishment or facility. 3.40 7.00",
  "checkedOn": "2026-10-10",
  "publisher": "Agencia Estatal Boletín Oficial del Estado"
}

quote is the source's own words, english its translation, and checkedOn the day it was checked against the source.

Next

On this page