EU VAT number validator API with caching
FastAPI service that validates EU VAT numbers via VIES, returns registered company details, and caches definitive results with a TTL.
pythonfastapihttpxpydanticpytestuv
Built 1 time, downloaded 0 times.
Install from your terminal
Run this in your project folder. It signs you in, shows what changes and asks before it overwrites a file.
npx claude-lab add eu-vat-number-validator-api-with-caching-8db9b3CLAUDE.md# the app
FastAPI service that validates EU VAT numbers against the VIES service and returns the registered company name and address. GET /vat/{country}/{number} returns a JSON object with the valid flag, name, address, source, cached status and checked_at time. Results are cached with a TTL, and a /health route reports service status without calling VIES.
## Commands
- `uv sync` installs dependencies from the lockfile.
- `uv run uvicorn app.main:app --reload` starts the dev server on port 8000.
- `uv run pytest -q` runs the tests.
- `uv run ruff check .` runs lint.
- `uv run ruff format --check .` checks formatting.
- `uv run mypy app` runs type checks on the app package.
## Layout
- `app/main.py`: routes and the app factory. Routes call service functions and hold no business logic.
- `app/vies_client.py`: HTTP client for VIES. This is the only module that makes network calls.
- `app/cache.py`: TTL cache with LRU eviction, behind a small Protocol with an injectable clock.
- `app/models.py`: Pydantic request and response models.
- `app/config.py`: settings read from environment variables.
- `tests/`: pytest tests. VIES is mocked with respx. Fixtures live in `tests/fixtures/`.
- `SPEC.md`: expected behavior. Tests should map to it.
- `PLAN.md`: phased build plan.
- `REVIEW.md`: review rules, imported below.
## Gotchas
- Normalize input first. Strip spaces, dashes and dots, uppercase the value, then check the 2-letter prefix.
- Greece uses the prefix EL, not GR. Check `vies-client` skill notes before changing prefix handling.
- VIES is sometimes unavailable or rate limited. Map upstream timeouts and 5xx responses to 503 with a Retry-After header.
- Never cache upstream errors or timeouts. Cache only definitive answers (valid or invalid).
- Never log a full VAT number at INFO level. Log a masked form.
- Use fake VAT numbers and recorded JSON in tests. Never commit real company data.
## Rules
- Use `httpx.AsyncClient` with an explicit timeout. The default is 5 seconds, set by an environment variable. Do not use bare `requests` calls.
- Keep all network calls inside `app/vies_client.py`.
- No secrets in code. Read config from environment variables and document each one in `.env.example` with placeholder values only. Never create or commit a real `.env`.
- Add a dependency only with a one-line reason in the PR description.
- Every change ends with the `verify` skill passing. Show the command output before saying a task is done. Do not skip steps.
- Use the injected clock in tests. Do not use `sleep()`.
@REVIEW.md
.claude/agents/test-runner.md---
name: test-runner
description: Runs pytest, ruff and mypy for this project and reports only failing tests and errors with file and line.
model: haiku
tools: Bash, Read, Grep
disallowedTools: Edit, Write
maxTurns: 8
---
You run the test and lint suite for the the app project and report results. You never edit files.
Run these commands in order from the project root:
1. `uv run pytest -q`
2. `uv run ruff check .`
3. `uv run mypy app`
Run all three even if an earlier one fails, so the report covers every problem.
If all three pass, reply with one line: `PASS` followed by the pytest summary line (for example `PASS 12 passed in 0.8s`). Nothing else.
If anything fails, reply with at most 30 lines in this form:
- For each failing pytest test: test name, the assertion or error message, and file:line.
- For each ruff or mypy finding: file:line, the rule code or error code, and the message.
- Group the output by tool, with a short heading for each: `pytest`, `ruff`, `mypy`.
Rules:
- Do not paste full tracebacks. Quote only the final assertion or error line.
- Use Grep or Read only to find the line number for a failure when the tool output does not give one.
- If a command cannot start (for example `uv` is missing), report that command and the error message in one line.
- Do not suggest fixes beyond naming the failing file and line.
- Never run commands that modify files, install packages, or use the network.
.claude/settings.json{
"model": "sonnet",
"permissions": {
"allow": [
"Bash(uv run pytest:*)",
"Bash(uv run ruff:*)",
"Bash(uv run mypy:*)",
"Bash(uv sync)",
"Bash(uv run uvicorn:*)",
"Bash(git status)",
"Bash(git diff:*)",
"Bash(git log:*)"
],
"ask": [
"Bash(git commit:*)",
"Bash(pip install:*)",
"Bash(uv add:*)",
"Bash(curl:*)",
"Bash(wget:*)"
],
"deny": [
"Bash(rm -rf:*)",
"Bash(git push:*)",
"Bash(git reset --hard:*)",
"Read(./.env)",
"Read(./.env.*)",
"Bash(curl:*ec.europa.eu*)"
]
}
}
.claude/skills/verify/SKILL.md---
name: verify
description: Verifies a change in this FastAPI VAT service by running tests, lint, format check, type check, and a live smoke run, and must pass before any task is called done.
user-invocable: true
disable-model-invocation: false
allowed-tools: Bash(uv run *), Bash(uv sync), Bash(curl -s http://127.0.0.1:8000/health)
---
# Verify
Use this skill to prove a change works. Run every step in order. Do not describe a change as done until this skill passes and you have shown the output.
## Steps
Run these from the project root, in this order:
1. `uv sync --frozen`
2. `uv run ruff format --check .`
3. `uv run ruff check .`
4. `uv run mypy app`
5. `uv run pytest -q`
6. Smoke run (below).
If a step fails, stop the run. Report that step first, with its key error lines. Do not continue to later steps and do not fix and retry inside this skill unless the user asks.
## Smoke run
1. Start the server in the background:
`uv run uvicorn app.main:app --port 8000`
2. Wait until it accepts connections, then check health:
`curl -s http://127.0.0.1:8000/health`
Expect a JSON body with `"status": "ok"`.
3. Check input validation with a malformed VAT number:
`curl -s -o /dev/null -w "%{http_code}" http://127.0.0.1:8000/vat/XX/abc`
Expect `422`. This must be rejected before any call to VIES.
4. Stop the server you started. Confirm it is no longer listening on port 8000.
If the server fails to start, report the startup error as the failing smoke step.
## Report format
Show one table with one row per step:
| Step | Result | Key output line |
|------|--------|-----------------|
| uv sync --frozen | pass or fail | last meaningful line |
| ruff format --check | pass or fail | summary line |
| ruff check | pass or fail | summary line |
| mypy app | pass or fail | "Success" or error count |
| pytest -q | pass or fail | pytest summary line |
| smoke: /health | pass or fail | status and body |
| smoke: /vat malformed | pass or fail | HTTP status code |
Below the table, list any failing step first with the exact error. Keep the table to what was run; do not invent results.
## Rules
- Do not mark the task done if any step was skipped. A skipped step counts as not verified.
- Do not hide warnings that change the result. Quote them.
- Do not run `git commit`, `git push`, or install new packages as part of verification.
- Do not use real VAT numbers in the smoke run. The malformed input above is intentional.
- Do not edit tests to make them pass inside this skill. Report the failure and let the user or the main task decide the fix.
- If pytest shows no tests collected, treat it as a failure, not a pass.
## When to run
- After every change, before reporting it complete.
- After each phase in PLAN.md, before the next `/goal` starts.
- Before the code-review step at the end of phases 3 and 5.
.claude/skills/vies-client/SKILL.md---
name: vies-client
description: Domain rules for EU VAT number format, country prefixes, and VIES REST responses, to use when touching normalization, the VIES client, or its tests.
---
# VIES client domain notes
Read this before changing normalization, the VIES client, or their tests. It records rules that are easy to get wrong.
## Normalization
Apply these steps in order to every incoming VAT number:
1. Remove spaces, dashes and dots.
2. Uppercase the whole string.
3. Split the first two characters as the country prefix.
4. Check the prefix against the table below.
5. Check the remainder against the pattern for that country.
Reject at step 4 or 5 with a validation error. Do not call VIES for rejected input. The API returns 422 in this case.
## Country prefixes
| Prefix | Country | Notes |
|--------|---------|-------|
| AT | Austria | |
| BE | Belgium | |
| BG | Bulgaria | |
| CY | Cyprus | |
| CZ | Czechia | |
| DE | Germany | |
| DK | Denmark | |
| EE | Estonia | |
| EL | Greece | Use EL, not GR. GR is not a valid VAT prefix. |
| ES | Spain | |
| FI | Finland | |
| FR | France | |
| HR | Croatia | |
| HU | Hungary | |
| IE | Ireland | |
| IT | Italy | |
| LT | Lithuania | |
| LU | Luxembourg | |
| LV | Latvia | |
| MT | Malta | |
| NL | Netherlands | |
| PL | Poland | |
| PT | Portugal | |
| RO | Romania | |
| SE | Sweden | |
| SI | Slovenia | |
| SK | Slovakia | |
| XI | Northern Ireland | Special case. Accepted by VIES under the UK protocol, not an EU member state. Treat as a valid prefix, but confirm against the official list before adding it to any allow list. |
Before changing this table, check the current official list of member state prefixes. Note the date you checked it in the commit message or a comment in the normalization module.
## Format patterns
- Each country has its own length and character pattern. Some are digits only, some mix letters and digits, and some have a check-digit rule.
- Keep one regex per country in the single normalization module from PLAN.md Phase 2. Do not spread patterns across files.
- Do not guess a pattern. If you are not sure of a country's pattern, check the official documentation first, or leave that country's pattern as a documented TODO and test only the prefix rule for it.
- Pattern checks are a pre-filter. VIES remains the source of truth for whether a number is registered.
## VIES response handling
VIES can return several different states. Map each to its own internal result type. Do not collapse them together.
- **valid**: the number is registered. Return name and address when VIES provides them, and treat them as possibly empty.
- **invalid**: the number is well-formed but not registered. Return `valid=false`, with name and address null.
- **upstream error**: VIES returned an error response or the service reported a fault. This is not invalid. Map to a domain error that the route turns into 503 with Retry-After.
- **maintenance or unavailable**: the service is down or in maintenance. Same handling as upstream error.
- **timeout or connection failure**: map to the same domain error as upstream error.
Rules that follow from this:
- Never write an upstream error or timeout to the cache. Only definitive valid and invalid results are cached.
- Never return `valid=false` because VIES was unreachable.
- Keep the mapping in `app/vies_client.py`. Routes must not parse VIES responses.
- Every network call goes through `app/vies_client.py` and uses an `httpx.AsyncClient` with an explicit timeout.
## Endpoint and fields
- Before changing the client, check the current VIES REST endpoint, request shape, and response field names in the official documentation. Do not rely on memory or on an old fixture.
- After checking, record the date in a comment at the top of `app/vies_client.py`, in the form `# Endpoint and fields checked against official docs: YYYY-MM-DD`.
- If the official response fields differ from the fixtures, update the fixtures and the mapping together in one change, and say so in the summary.
## Tests and fixtures
- Put recorded responses in `tests/fixtures/`. Use JSON files named by state, for example `valid.json`, `invalid.json`, `unavailable.json`.
- Use clearly fake numbers in all tests and fixtures. Use obvious placeholders with repeated digits or sequential digits, and no real company data.
- Never commit a real company's name, address, or VAT number, even if it is public. The one exception is the manual live check the user runs by hand, which stays out of the repo.
- Mock all HTTP with respx. No test may reach the real VIES service.
- Test every state in the mapping list above, plus normalization for spaces, dashes, dots, lowercase input, and the Greece EL prefix.
- Use the injected clock for any time-based assertion. Do not call `sleep()`.
## Reminders for Claude
- When a task touches these rules, read this file first, then the check described under "Endpoint and fields".
- If the official documentation disagrees with this file, stop, report the difference, and update this file only after the user confirms.
- Run the `verify` skill after any change here.
INSTALL.md# Install and start
This guide sets up the the app project and walks you through building it with Claude Code, one phase at a time.
## Prerequisites
- Claude Code installed and signed in.
- Python 3.12 or newer.
- uv, the Python package manager:
- macOS or Linux: `curl -LsSf https://example.com | sh`
- Windows: `winget install astral-sh.uv`
- Open a new terminal after installing.
- git.
## What each file does
- `CLAUDE.md`: project memory for Claude. Commands, layout, and gotchas.
- `SPEC.md`: the behavior the API must have. Tests are derived from it.
- `PLAN.md`: the five build phases, each with a `/goal` prompt you paste into Claude Code.
- `REVIEW.md`: review rules that `/code-review` and human reviewers follow.
- `INSTALL.md`: this guide.
- `.claude/settings.json`: shared permissions and the default model (Sonnet).
- `.claude/agents/test-runner.md`: a read-only helper that runs tests and lint and returns only failures.
- `.claude/skills/verify/SKILL.md`: the checks Claude must pass before saying a task is done.
- `.claude/skills/vies-client/SKILL.md`: notes on VAT number format and VIES responses.
## Set up the folder
1. Create the project folder and run `git init` inside it.
2. Copy the files listed in PLAN.md into the folder.
3. Create the Python project:
```
uv init --package the app --python 3.12
uv add fastapi uvicorn httpx pydantic-settings
uv add --dev pytest pytest-asyncio respx ruff mypy
```
4. Create `.env.example` with placeholder values, for example `CACHE_TTL_SECONDS=86400` and `VIES_TIMEOUT_SECONDS=5`.
5. Add `.env` to `.gitignore`. Never commit a real `.env` file.
## Install the recommended plugins
Start Claude Code in the project folder:
```
claude
```
Inside Claude Code, install each plugin:
```
/plugin install code-review@claude-plugins-official
/plugin install security-guidance@claude-plugins-official
/plugin install commit-commands@claude-plugins-official
```
Then run `/reload-plugins`, or restart Claude Code.
## Run the first phase
1. Open PLAN.md and find the Phase 1 `/goal` prompt.
2. Copy it and paste it into Claude Code.
3. Let it finish. Read the verify report it prints before going on.
4. Do the same for each phase, in order.
Between phases:
- Run `/clear` to start a fresh conversation for the next phase.
- Run `/compact` if a session gets long.
- Run `/code-review` after Phase 3 and after Phase 5.
## Stop a runaway goal
If a goal runs too long or goes off track, run:
```
/goal clear
```
Then describe the problem in plain words, or start the phase again with `/clear` first.
## Cost and effort
- Sonnet is the default model, set in `.claude/settings.json`. It is the cheaper choice for daily work.
- Use `/effort high` only for the VIES client and cache design work.
- Use `/usage` to see where tokens went.
## Test by hand
Start the server:
```
uv run uvicorn app.main:app --reload
```
Then open http://127.0.0.1:8000/docs in your browser.
- After Phase 1, the page shows `/health`.
- After Phase 3, it also shows the VAT endpoint, so you can try it from the page.
The first live call to VIES should be done by you, not by Claude. Use a VAT number you own or a public company number.
PLAN.md# Build plan
Five phases. Run them in order. Each phase ends with a measurable `/goal` prompt you can paste into Claude Code. Read the verify report before starting the next phase.
Before any phase: run `/clear` if the previous phase left a long session.
## Phase 1: scaffold
**Scope:** uv project, FastAPI app factory, `/health`, config from environment variables, pytest, ruff and mypy configured in `pyproject.toml`, `.env.example`.
**Files touched:** `pyproject.toml`, `app/__init__.py`, `app/main.py`, `app/config.py`, `tests/test_health.py`, `.env.example`, `.gitignore`.
**Goal prompt:**
```
/goal Phase 1 of PLAN.md is complete: a uv-managed FastAPI project named the app exists with app/main.py exposing an app factory and GET /health returning {"status":"ok"}, settings loaded from environment variables in app/config.py, pyproject.toml configured for ruff, mypy (strict on app/) and pytest, .env.example with placeholder values, and a .gitignore that excludes .env. Measurable end state: `uv sync --frozen && uv run ruff format --check . && uv run ruff check . && uv run mypy app && uv run pytest -q` exits 0, and that output, including the pytest summary line showing at least one passing test for /health, is shown in this conversation. Constraints: do not call any external network service; add no dependencies beyond fastapi, uvicorn, httpx, pydantic-settings, pytest, pytest-asyncio, respx, ruff and mypy; do not create a real .env file; do not commit; do not modify SPEC.md or REVIEW.md; keep changes to the Phase 1 files. Use the verify skill. Stop after 30 turns and report what is blocking if the end state is not reached.
```
**Check by hand:**
- Run `uv run uvicorn app.main:app --reload` and open http://127.0.0.1:8000/health. Expect `{"status":"ok"}`.
- Confirm `.env` is listed in `.gitignore`.
## Phase 2: VIES client and normalization
**Scope:** pure functions for normalization and validation, tested first. An httpx client with an explicit timeout, mocked with respx in tests. Upstream errors mapped to domain exceptions.
**Files touched:** `app/vies_client.py`, `app/models.py`, `tests/test_normalize.py`, `tests/test_vies_client.py`, `tests/fixtures/` (fake numbers and recorded JSON only).
**Order inside the phase:**
1. Normalization and format checks, with tests. No network.
2. Domain exceptions: invalid format, VIES unavailable, VIES timeout.
3. The httpx client with a timeout read from `VIES_TIMEOUT_SECONDS`. Tests use respx.
**Goal prompt:**
```
/goal Phase 2 of PLAN.md is complete: app/vies_client.py contains pure normalization and format functions, domain exceptions for invalid format, VIES unavailable and VIES timeout, and an httpx.AsyncClient wrapper with an explicit timeout read from config. Tests in tests/test_normalize.py and tests/test_vies_client.py cover normalization (spaces, dashes, dots, lowercase, EL prefix for Greece, XI for Northern Ireland), a valid response, a not-valid response, a 5xx response, and a timeout, all with respx. Measurable end state: `uv run pytest -q && uv run mypy app && uv run ruff check .` exits 0, and the output is shown in this conversation. Constraints: no real network calls in tests; no real VAT numbers anywhere, only fake numbers and recorded fixtures; no bare requests calls; no new dependencies; no commit; do not modify SPEC.md or REVIEW.md. Use the verify skill. Stop after 30 turns and report what is blocking.
```
**Check by hand:**
- Make the first live VIES call yourself, not Claude. Use a number you own or a public company number. Example:
`curl -s "https://example.com<number>"`
- Compare the live response shape with the recorded fixture in `tests/fixtures/`. If they differ, update the fixture and the client yourself, then tell Claude.
- The `vies-client` skill tells Claude to check the official VIES documentation first. Confirm it did.
## Phase 3: GET /vat endpoint
**Scope:** wire the route to the client. Test the 200, 422 and 503 paths. Document the response model in OpenAPI.
**Files touched:** `app/main.py`, `app/models.py`, `tests/test_vat_routes.py`.
**Rules for this phase:**
- The route calls a service function. It does no business logic itself.
- Map domain exceptions to status codes in one place (the top-level error handler).
- Do not add the stats route yet. That is Phase 4.
**Goal prompt:**
```
/goal Phase 3 of PLAN.md is complete: GET /vat/{country_code}/{vat_number} is registered in app/main.py and calls a service function. It returns the SPEC.md JSON shape on 200 (valid and not valid), 422 for invalid format without calling VIES, and 503 with a Retry-After header when VIES fails. The response model appears in the OpenAPI schema at /openapi.json. Tests in tests/test_vat_routes.py cover each of those status codes, with VIES mocked by respx. Measurable end state: `uv run pytest -q && uv run mypy app && uv run ruff check . && uv run ruff format --check .` exits 0, and the output is shown in this conversation. Constraints: routes contain no business logic; no real network calls; no new dependencies; no commit; do not modify SPEC.md or REVIEW.md. Use the verify skill. Stop after 30 turns and report what is blocking.
```
**Check by hand:**
- Open http://127.0.0.1:8000/docs and try the endpoint with a fake number. Expect 200 with `valid` false, or 422 for a malformed value.
- Run `/code-review` now. Fix any block-level findings before Phase 4.
## Phase 4: TTL and LRU cache
**Scope:** cache behind a Protocol, injectable clock, TTL expiry, LRU eviction, `cached` flag in responses, optional stats endpoint.
**Files touched:** `app/cache.py`, `app/config.py`, `app/main.py`, `app/models.py`, `tests/test_cache.py`, `tests/test_vat_routes.py`.
**Rules for this phase:**
- The cache is a Protocol with an in-memory implementation.
- The clock is injected. Tests advance it. No `sleep()`.
- Only definitive answers are cached. Errors are never cached.
- Register `/vat/cache/stats` before the lookup route.
**Goal prompt:**
```
/goal Phase 4 of PLAN.md is complete: app/cache.py defines a Cache Protocol and an in-memory TTL + LRU implementation that takes an injectable clock. Cache keys are normalized country plus number. Only valid and not-valid answers are stored; VIES errors are never stored. Responses set cached=true on hits. GET /vat/cache/stats returns hits, misses and size, is registered before the lookup route, and returns 404 when CACHE_STATS_ENABLED is false. Tests in tests/test_cache.py cover hit, miss, TTL expiry with a fake clock, LRU eviction at capacity, and errors not cached. Measurable end state: `uv run pytest -q && uv run mypy app && uv run ruff check .` exits 0, and the output is shown in this conversation. Constraints: no sleep() in tests; no new dependencies; no real network calls; no commit; do not modify SPEC.md or REVIEW.md. Use the verify skill. Stop after 30 turns and report what is blocking.
```
**Check by hand:**
- Call the same fake number twice. The second response should show `"cached": true`.
- Set `CACHE_STATS_ENABLED=false` in your local `.env`, restart, and confirm `/vat/cache/stats` returns 404.
- Run `/code-review` after this phase.
## Phase 5: hardening and packaging
**Scope:** structured logging with masked VAT numbers, request timeouts confirmed, Dockerfile (python slim, non-root user), README run instructions.
**Files touched:** `app/logging_setup.py`, `app/main.py`, `Dockerfile`, `.dockerignore`, `README.md`, `tests/test_logging.py`.
**Rules for this phase:**
- Log VAT numbers masked at INFO, for example `DE***789`. Full numbers are not logged.
- The Dockerfile runs as a non-root user and does not copy `.env`.
- Keep the broad `except Exception` only in the top-level error handler.
**Goal prompt:**
```
/goal Phase 5 of PLAN.md is complete: logging in app/logging_setup.py writes structured JSON logs and masks VAT numbers at INFO (for example DE***789), with a test in tests/test_logging.py that asserts a full number never appears in captured log output. A Dockerfile uses python:3.12-slim, runs as a non-root user, and excludes .env through .dockerignore. README.md has run instructions for uv and Docker. Measurable end state: `uv run pytest -q && uv run mypy app && uv run ruff check . && uv run ruff format --check .` exits 0, and the output is shown in this conversation. If Docker is installed, `docker build -t the app .` also exits 0 and its output is shown. Constraints: no real secrets; no new dependencies unless named in the README with a reason; no commit; do not modify SPEC.md or REVIEW.md. Use the verify skill. Stop after 30 turns and report what is blocking.
```
**Check by hand:**
- Run `docker run --rm -p 8000:8000 the app` and confirm `/health` answers.
- Read a few log lines from a lookup and confirm the VAT number is masked.
- Run `/code-review` and `security-guidance` on the full diff before you merge.
- Commit yourself. Claude is not allowed to commit without asking.
## After all phases
- Run `/code-review` against the whole branch.
- Check every status code in SPEC.md has at least one test (REVIEW.md rule).
- Read `/usage` to see where tokens went, and note anything to change for the next project.
REVIEW.md# Review rules
Used by `/code-review` (imported through CLAUDE.md) and by people reviewing changes.
## Block (fix before merge)
- Network calls outside `app/vies_client.py`. Routes, cache and models never make HTTP requests.
- HTTP clients without an explicit timeout.
- Caching of error or timeout responses. Only definitive VIES answers (valid or invalid) may be cached.
- Secrets, API keys, or real VAT numbers in code, tests, fixtures, or logs. Use fake numbers under tests/fixtures/.
- Logs that print a full VAT number at INFO or above. Log the masked form.
- Routes that contain business logic. Routes call a service function and return its result.
- New dependency without a one-line reason in the PR description.
## Warn (fix or justify in the PR)
- Public function without type hints.
- Test without an assertion.
- `sleep()` in tests. Use the injected clock instead.
- Broad `except Exception` outside the top-level error handler.
## Check (confirm during review)
- Each status code in SPEC.md (200, 422, 503) has at least one test.
SPEC.md# the app spec
This spec defines the behavior of the v1 service. Tests are derived from these rules.
## Endpoints
### GET /vat/{country_code}/{vat_number}
Looks up an EU VAT number in VIES and returns the result.
Success returns 200 with this JSON body:
```json
{
"country_code": "DE",
"vat_number": "123456789",
"valid": true,
"name": "Acme",
"address": "1 Main Street, the city",
"source": "vies",
"cached": false,
"checked_at": "2026-01-15T10:30:00Z"
}
```
- `country_code` and `vat_number` in the response are the normalized values.
- `source` is `vies` for a fresh lookup. Cache hits keep the original source.
- `checked_at` is the UTC time VIES answered, not the time of the request.
### Input normalization
Normalize before validation and before the cache key is built:
- Remove spaces, dashes and dots.
- Uppercase the whole value.
- Split the 2-letter prefix as the country code.
- Greece uses the prefix `EL`, not `GR`.
- Northern Ireland uses the prefix `XI`, a special case handled in the country table.
### Invalid format
- An unknown country prefix or a number that fails the country pattern returns 422.
- The response body names the field that failed.
- VIES is not called.
- Nothing is written to the cache.
### VIES says not valid
- Returns 200 with `valid` set to `false`.
- `name` and `address` are `null`.
- This answer is definitive and is cached like a valid answer.
### VIES unavailable or timeout
- Connection errors, timeouts, upstream 5xx responses and VIES maintenance states return 503.
- The response includes a `Retry-After` header with a number of seconds.
- Nothing is written to the cache. Errors are never cached.
- An upstream error is never reported as `valid: false`.
## Cache
- Default TTL is 24 hours (86400 seconds), set by `CACHE_TTL_SECONDS`.
- The cache key is the normalized country code plus the normalized number.
- A hit returns the stored result with `cached` set to `true`.
- A miss calls VIES and stores only definitive answers (valid or not valid).
- Maximum size defaults to 10000 entries, set by `CACHE_MAX_ENTRIES`.
- When full, the least recently used entry is evicted (LRU).
- Expired entries are never returned, even if they have not been evicted yet.
## Health
### GET /health
- Returns 200 with `{"status": "ok"}`.
- Does not call VIES and does not touch the cache.
## Optional stats endpoint
### GET /vat/cache/stats
- Returns 200 with `hits`, `misses` and `size`.
- Can be disabled with `CACHE_STATS_ENABLED=false`. When disabled, the path returns 404.
- Register this route before `/vat/{country_code}/{vat_number}`. Otherwise the lookup route matches `cache` as a country code and returns 422.
## Configuration
All config comes from environment variables. Defaults are in `app/config.py`.
| Variable | Default | Purpose |
|---|---|---|
| `VIES_TIMEOUT_SECONDS` | 5 | Timeout for each VIES request |
| `CACHE_TTL_SECONDS` | 86400 | Time to live for cache entries |
| `CACHE_MAX_ENTRIES` | 10000 | Maximum cache size before LRU eviction |
| `CACHE_STATS_ENABLED` | true | Enables `/vat/cache/stats` |
Real values never go in code. Document placeholders in `.env.example`.
## Out of scope for v1
These are future work only. Do not build them in v1:
- Authentication
- Rate limiting per client
- Persistent storage
- Batch lookups
## Acceptance
pytest must cover:
- Normalization, including the `EL` prefix and separator removal.
- Every status code in this spec: 200 (valid and not valid), 422, 503 with `Retry-After`, and 404 for stats when disabled.
- Cache hit and cache miss, with `cached` flag set correctly.
- TTL expiry using an injected clock, with no `sleep()` calls.
- LRU eviction when the cache is full.
- Errors are not cached.
VIES calls are mocked with respx. The test suite makes no real network calls.