AXLR / Catalog / administration

es_tax_id_validate

Validate Spanish tax identifiers: the DNI/NIF of a citizen, the NIE of a foreign national, the special K, L and M NIFs, and the NIF of a company or other legal entity (the former CIF) with its entity type and province code. A wrong check letter or digit comes back with the correct one.

Install

/tools install es_tax_id_validate
/tools install es_tax_id_validate@1.0.0

In a console session, or let the model find it in normal mode: axlr_catalog describes it and axlr_install_tool asks for the same install card. The card reads catalog axlr · es_tax_id_validate 1.0.0 · verified once the index signature, the manifest digest and every file digest check out. Then the model runs it with axlr_run_tool es_tax_id_validate; each run is approved as a local_exec.

Arguments

One JSON object on stdin, validated against the schema before the program starts.

nametyperequireddefaultmeaning
idstringnoSpaces, dots, dashes and an ES prefix are ignored
idsarray of stringno
Input schema (JSON)
{
  "type": "object",
  "additionalProperties": false,
  "properties": {
    "id": {
      "type": "string",
      "description": "Spaces, dots, dashes and an ES prefix are ignored"
    },
    "ids": {
      "type": "array",
      "items": {
        "type": "string"
      }
    }
  }
}

Example

Input

{
  "id": "Q2826000H"
}

Output (stdout)

{
  "valid": true,
  "type": "NIF_entity",
  "entity": "organismo público"
}

Files

The command the install card registers is python3 es_tax_id_validate.py, run in the workspace with PATH and HOME as its only environment. Each file's SHA-256 is in the signed index; the console checks them before showing the card and again before writing.

es_tax_id_validate.py · sha256 7cc47b2144c95b5343b2df358ab0e3b73eb6c6ed4c6b024f0be77d3a9b3033c6
#!/usr/bin/env python3
"""Spanish tax identifiers: DNI/NIF of a citizen (8 digits and a letter), NIE of a foreigner
(X, Y or Z, 7 digits and a letter), special NIFs (K, L, M), and the NIF of a legal entity
(the former CIF: an entity letter, a province code, a serial and a check digit or letter).
Each is checked and its type explained; a wrong check character comes with the right one.

Input (JSON on stdin):
  id    "12345678Z"     or
  ids   ["X1234567L", "B12345674", ...]
"""
import json
import re
import sys

LETTERS = "TRWAGMYFPDXBNJZSQVHLCKE"
ENTITY_LETTER = "JABCDEFGHI"
ENTITY_TYPES = {
    "A": "sociedad anónima", "B": "sociedad de responsabilidad limitada", "C": "sociedad colectiva",
    "D": "sociedad comanditaria", "E": "comunidad de bienes o herencia yacente", "F": "sociedad cooperativa",
    "G": "asociación o fundación", "H": "comunidad de propietarios en régimen de propiedad horizontal",
    "J": "sociedad civil", "N": "entidad extranjera", "P": "corporación local", "Q": "organismo público",
    "R": "congregación o institución religiosa", "S": "órgano de la Administración del Estado o de las comunidades autónomas",
    "U": "unión temporal de empresas", "V": "otro tipo de entidad", "W": "establecimiento permanente de entidad no residente",
}
LETTER_ONLY, DIGIT_ONLY = set("PQRSNW"), set("ABEH")


def fail(message):
    print(json.dumps({"error": message}))
    sys.exit(2)


def entity_control(digits):
    odd = sum(sum(divmod(2 * int(d), 10)) for d in digits[0::2])
    even = sum(int(d) for d in digits[1::2])
    control = (10 - (odd + even) % 10) % 10
    return str(control), ENTITY_LETTER[control]


def check(raw):
    if not isinstance(raw, str):
        return {"input": raw, "valid": False, "reason": "not a string"}
    value = re.sub(r"[\s.\-/]", "", raw).upper()
    if value.startswith("ES") and len(value) == 11:
        value = value[2:]
    out = {"input": raw, "normalized": value, "valid": False}
    if re.fullmatch(r"\d{8}[A-Z]", value):
        want = LETTERS[int(value[:8]) % 23]
        out.update({"type": "DNI", "description": "NIF of a Spanish citizen (DNI number and letter)"})
    elif re.fullmatch(r"\d{1,7}[A-Z]", value):
        padded = value[:-1].zfill(8) + value[-1]
        return {**check(padded), "input": raw, "note": "padded with leading zeros to 8 digits"}
    elif re.fullmatch(r"[XYZ]\d{7}[A-Z]", value):
        want = LETTERS[int(str("XYZ".index(value[0])) + value[1:8]) % 23]
        out.update({"type": "NIE", "description": "identity number of a foreign national"})
    elif re.fullmatch(r"[KLM]\d{7}[A-Z]", value):
        want = LETTERS[int(value[1:8]) % 23]
        kinds = {"K": "Spaniard under 14 without DNI", "L": "Spaniard resident abroad without DNI", "M": "foreigner without NIE"}
        out.update({"type": "NIF_" + value[0], "description": f"special NIF: {kinds[value[0]]}"})
    elif re.fullmatch(r"[ABCDEFGHJNPQRSUVW]\d{7}[0-9A-J]", value):
        digit, letter = entity_control(value[1:8])
        kind = value[0]
        allowed = [letter] if kind in LETTER_ONLY else [digit] if kind in DIGIT_ONLY else [digit, letter]
        out.update({"type": "NIF_entity", "entity": ENTITY_TYPES[kind], "province_code": value[1:3], "description": "NIF of a legal entity (formerly CIF)"})
        out["valid"] = value[-1] in allowed
        if not out["valid"]:
            out["expected_check"] = allowed if len(allowed) > 1 else allowed[0]
            out["reason"] = "wrong check character"
        return out
    else:
        out["reason"] = "not the shape of a DNI, NIE, special NIF or entity NIF"
        return out
    out["valid"] = value[-1] == want
    if not out["valid"]:
        out["expected_check"] = want
        out["reason"] = "wrong check letter"
    return out


def main():
    try:
        request = json.load(sys.stdin)
    except ValueError as error:
        fail(f"stdin is not JSON: {error}")
    if not isinstance(request, dict):
        fail("input must be a JSON object")
    if "id" in request:
        print(json.dumps(check(request["id"]), ensure_ascii=False))
        return
    ids = request.get("ids")
    if not isinstance(ids, list) or not ids:
        fail("give id or a non-empty ids list")
    results = [check(i) for i in ids]
    print(json.dumps({"results": results, "valid": sum(r["valid"] for r in results), "invalid": sum(not r["valid"] for r in results)}, ensure_ascii=False))


if __name__ == "__main__":
    main()

Where it is served

The registry serves only AXLR clients, so a browser gets 403; fetch with a User-Agent that starts with axlr:

curl -A axlr-docs/1.0 -O https://registry.underpassai.com/tools/es_tax_id_validate/1.0.0/manifest.json
curl -A axlr-docs/1.0 -O https://registry.underpassai.com/tools/es_tax_id_validate/1.0.0/es_tax_id_validate.py