Skip to content
vinter-manPublic

About

Unofficial Telegram bot: full employer cost in 5 countries, EOR vs your own entity, and the break-even point. Python, aiogram, Pydantic.

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Repository files navigation

CI Python 3.12+ aiogram 3 coverage 90%+ Telegram bot License: MIT

main picture

Unofficial, estimates only, not legal or tax advice. Not affiliated with Deel. Every figure has a source you can open from the bot with /sources.

Short description

Two engineers in Berlin, one in London, one in Madrid, one in Warsaw. Open four companies, or hire them through an Employer of Record? Type the team as you would say it — head count and salary per country, each in its own currency: Germany 2 × 80k, UK 1 × £60k, Spain 1 × 50k, Poland 1 × 200k zł

team

Four entities would cost ~€24,000 more in the first year — before anyone counts the work of running a company in four countries. That is the case EOR exists for, and the bot shows it per country: Deel's list price against what a local company really costs — setting it up, running it (accountant, address, bank, chamber fees, occupational health, the UK audit), the weeks before it can run its first payroll, and closing it again — and the team size from which a company of your own would start to pay off, run on Deel Payroll. Every line has a source.

And one thing I did not plan for: when Deel's own public country guide shows a figure that has since changed, the bot says so, and /sources shows it side by side — Deel 0.06%, official 0.15% (§ 360 SGB III). I compared the guides with the official sources by hand and found six in five countries; a script in the repo re-checks that the quoted figures are still on Deel's pages.

Try it: @deel_hea_unofficial_bot. It speaks the languages of the countries it covers — English, German, Spanish, Polish and Bulgarian — and asks which on the first /start.

demo

Germany, United Kingdom, Spain, Poland and Bulgaria, with 2026 rates.


Table of contents



User guide

First steps

/start, pick a language, and tap one of the three examples — or just type.

start

:suspect: The bot remembers exactly one thing about you: the language you picked. Not your id even — the key is an HMAC of your Telegram id under a secret that never leaves the server, so the table cannot be joined back to an account. No names, no queries, no message text in the logs; /stats (owner only) is a counter per command and nothing else. prefs.py.

Asking about a team

Each country, its head count and its annual gross salary — Germany 2 × 80k, UK 1 × £60k, Spain 1 × 50k. A salary is in that country's currency unless you write another (UK 1 × $90k); 5000/mo (or pro Monat, al mes, miesięcznie, на месец) is a monthly one. Commas, new lines or nothing between countries; 2 × 80k, 2 80k and 80k 2 people all read the same. If everybody earns the same, say it once, set off with a dash, comma or colon: Germany 2, Spain 1 — 70k (in euros). A horizon goes at the end: … 24 months.

One country is a team too:

one country

The answer is a chart with a short caption: per country, EOR fees against what a company of your own costs there, the totals, the team size from which a company would pay off in each country, and buttons that redraw it for 3, 6 or 12 months and open each country's sources. If the countries disagree — EOR cheaper in some, a company in others — the caption names the cheapest mix, with the companies run on Deel Payroll.

:suspect: A number right after a country is its head count (Germany 2), unless the next word makes it an amount (80 tys., 900 /mo, 500 EUR) or it starts a grouped one (Germany 150 000). A bare Germany 500 is read as a (too small) salary rather than five hundred hires. A country without a salary (Germany 2 × 80k, Spain 1, or Germany 2, Poland 1 × 200k — that 200k is Poland's, in złoty) is an error that names the country, not a guess.

The chart is matplotlib on the Agg backend, drawn in a worker thread with a font shipped in the package, so it looks the same on the server as here. If drawing fails, or the caption would pass Telegram's 1,024 characters, the bot sends the whole breakdown as text instead. charts.py.

How the comparison works

Salary and employer contributions are identical either way — an EOR invoices them through — so they are counted once and cancel out. What is compared is everything else:

Own company Where the number comes from
setup: registration, notary, formation for a foreign owner official fee schedules, law-firm price pages
running: accountant, address, bank, chamber fees, occupational health official fees, provider price pages
the UK statutory audit, required unless the whole group is small a UK audit firm's published starting fee
the setup weeks, during which the team is on the EOR anyway law firms, RSM and Grant Thornton guides for a foreign-owned company: 6–16 weeks
closing the company at the end of the horizon liquidation fees and statutory waiting periods
payroll per person Deel Payroll's list price

:hurtrealbad:

company cheaper for N people over M months  ⇔
    N · (M − setup months) · (fee − payroll per head)  >  setup + exit + M · fixed

The fixed costs run from incorporation, setup months included (the company keeps books and an address before its first payroll), and the exit is paid at the end of the horizon — so "pays off over M months" means "if you closed it at month M". That gives the smallest team for a horizon, strictly (equal cost is not "cheaper"); a test checks the closed form against brute force.

What the numbers say. With Deel's list price and every sourced cost, a single hire is cheaper on the EOR for the whole first year in all five countries, two people are in Germany, Spain and the UK, and a company pays off from two to four people over a year. Where a source gives a range the data takes a mid-range value, and /sources links the range. The EOR side is Deel's public "starting at" price; real quotes vary by country and contract, in both directions. In the UK the company carries a statutory audit (£811 of its £994 a month), which applies unless your whole group is small — without it a UK company already pays off for a single hire over a year, the one case where the EOR loses that early.

What is not priced, and would only move the line towards the EOR: your team's own time on the company (management, HR, working with the accountant), the months a liquidation takes (a GmbH needs 14–24, with its fixed costs still running), and the misclassification and permanent-establishment exposure an EOR takes on. I left them out rather than guess: an earlier version priced the team's time with the World Bank's Paying Taxes hours, until a review pointed out that those hours are for a 60-employee firm and overlap with the accountant and payroll fees already counted. There is no hidden "risk" percentage either — nobody publishes one I could cite — so penalty regimes (Germany's 1% a month on late contributions, Spain's 10–20% surcharges, ZUS's up to 100%) are listed in /sources instead of being turned into a number.

Where every number comes from

/sources Poland, or the 📚 button under an answer — every rate with its official source (government, social-security agency, official gazette), every company cost line with its source, Deel's price page, and the Deel guide figure by figure: ✅ agrees, 📝 differs.

sources

:rage1: What the comparison found on 29.10.2026 — each a figure from Deel's public country guide against the official source:

Country Deel's guide Official 2026
🇩🇪 long-term care, employer 1.7% 1.8%
🇩🇪 insolvency levy 0.06% 0.15% (§ 360 SGB III)
🇬🇧 National Living Wage £12.21/h (in a table headed "As of April 2025") £12.71/h from 01.04.2026
🇵🇱 pension/disability ceiling PLN 260,190 (the 2025 limit) PLN 282,600
🇧🇬 maximum insurable income BGN 3,750 €2,300 (euro since 01.01.2026)
🇪🇸 minimum wage €1,262.28/mo €1,221/mo × 14 (SMI)

The bot never scrapes Deel at runtime. scripts/check_deel_guides.py re-checks those pages by hand: it reads robots.txt first, fetches one page at a time with a pause (or the crawl delay robots.txt asks for), under its own User-Agent, and looks for each stored figure's verbatim quote. A quote that is gone means the page changed and a person should look; the script never edits the data itself. It also runs as a manual GitHub Action.

The bot checks freshness on its own too, once a day after the ECB publishes: rates for an earlier year than today's, official figures not re-checked for six months, or exchange rates older than four days end up as a warning in the reply and a message to the owner.

When you type something odd

The parser is a tokenizer, not an LLM: country names in all five languages and their codes (Deutschland, Hiszpania, България, UK), 80k, 80 000, 80.000, 1,2 Mio., 80 tys., €60k, 60000 zł, 2 ×, 5 people, x5, 5 Personen, 3 osoby, 24 months, 2 años. Whatever it does not recognise is named back to you, and a contradiction (80k 5 people 6 people, 80k 90k) is an error rather than a guess.

errors

:suspect: Telegram does not check a button's callback data against the message it came from, so every button is validated again when it comes back, and a forged or stale one gets a polite alert instead of a traceback. A team with a salary per country does not fit in Telegram's 64 bytes anyway, so the horizon buttons carry a short key into an in-memory store of recent teams — numbers only, nothing about who asked — and an evicted key is just a stale button. handlers.py, parser.check_team.



Commands

Command What it does
/team <country> <people> × <salary>, … [months] EOR vs a company of your own, per country, as a chart
/sources <country> every source, check dates, where Deel's guide differs
/lang change language
/help how to ask, in your language

Anything else you type is read as a team: Germany 2 × 80k, Германия 2 × 80k, България 1 × 30k, Germany 2, Spain 1 — 70k.



The data

One JSON file per country in deel_advisor/data/countries/, validated at startup by a Pydantic schema (models.py) — a file that does not fit stops the bot from starting rather than producing a wrong answer:

{
  "country": "Germany", "code": "DE", "currency": "EUR", "year": 2026, "effectiveFrom": "2026-01-01",
  "contributions": [{ "id": "pension", "name": "Pension insurance", "rate": "0.093",
                      "cap": "8450", "capPeriod": "month", "source": "https://www.deutsche-rentenversicherung.de/…" }],
  "minimumWage": { "amount": "13.90", "period": "hour", "hoursPerYear": 2080, "source": "…" },
  "entity": { "setupCost": [{ "name": "…", "amount": "225.00", "kind": "official", "source": "…" }],
              "monthlyFixed": [{ "name": "Payroll", "amount": "25.00", "perEmployee": true, "kind": "estimate", "source": "…" }] },
  "deelGuide": { "url": "https://www.deel.com/hiring/employees/germany/", "checkedAt": "2026-10-29",
                 "figures": { "care_rate": { "value": "0.017", "kind": "rate", "quote": "Long-term insurance fee - 1.7%" } } },
  "official":  { "checkedAt": "2026-10-29",
                 "figures": { "care_rate": { "value": "0.018", "kind": "rate", "source": "https://www.bundesgesundheitsministerium.de/…" } } }
}

The EOR price lives in eor.json — "Starting at $599 per EOR employee per month", checked on deel.com/pricing on 29.10.2026. Entity costs are mid-range estimates from official fee schedules where they exist and from providers' public price pages where they do not; each line says which.

:godmode: Salary plus contributions is worked out as a real payslip — the annual salary split into twelve monthly payslips, each contribution charged on its own slice, rounded half-up to the cent — and one formula covers every country: rate × max(0, min(salary, cap) − floor). A plain ceiling is a cap (German pension stops at €8,450 a month). A threshold is a floor (UK employer NIC: 15% above £5,000 a year). Both make a band — the UK workplace pension is 3% of earnings between £6,240 and £50,270, and Spain's 2026 solidarity contribution is three bands stacked above the €5,101.20 ceiling.

All money is Decimal. Rates and amounts in the data files are JSON strings — the schema refuses 0.093 as a number, because by the time Python sees it, it is already 0.09299999999999999. calc.py, money.py.

:shipit: Tests hold the data to its word: payslips for every country worked out by hand (Germany at €80k is €98,854.68 a year, Spain's three solidarity bands at €120k, Poland above its ceiling…), every figure the bot calls "official" must be the number it actually calculates with, and every figure compared with Deel's guide must have a quote the checker can find again. tests/test_data.py.



Installation

You need a bot token from @BotFather and uv, which brings the right Python with it.

git clone https://github.com/vinter-man/deel-bot && cd deel-bot
cp .env.example .env          # BOT_TOKEN, optionally OWNER_ID
uv sync
uv run python -m deel_advisor

Docker

cp .env.example .env
docker compose up -d --build
docker compose logs -f

Read-only root filesystem, no capabilities, no published ports (long polling needs none), and a healthcheck on a heartbeat file: a background task calls Telegram's getMe every minute and touches it on success, so "healthy" means the process is alive and can reach Telegram. The data volume holds the FX cache and the language table.

On a VPS

deploy/README.md: a push to main runs the CI gate — ruff, prettier, mypy --strict, the tests with a 90% coverage floor, shellcheck, an image build that imports every module from the installed wheel — and only then SSHes in with a key that can run one script. That script deploys the tested commit, waits for the healthcheck and rolls back if the new version does not come up.



Under the hood

text ─→ parser ─→ fx (ECB) ─→ calc ─→ freshness ─→ replies + charts ─→ Telegram
        tokens     daily cache   Decimal   Deel vs official   en de es pl bg
Module What is in it
calc.py every calculation and nothing else: payslip lines, company cost, break-even
models.py the schema of the country files
parser.py text → a validated Team, errors with names
fx.py ECB reference rates: daily fetch, cache on disk, bundled snapshot as a fallback
freshness.py outdated year, unchecked figures, Deel guide vs official
replies.py Telegram HTML; everything the user typed is escaped
charts.py the answer's chart: matplotlib, ink on paper
i18n.py the copy in five languages, with CLDR plural categories and case-aware counts
bot/ aiogram handlers, keyboards and callback data, the daily job and the heartbeat
scripts/ the Deel guide checker and the screenshot exporter

Replies are Telegram HTML rather than MarkdownV2: only <, > and & need escaping, and nobody has to escape a full stop in €1,221.00.

uv sync
uv run pytest -q --cov        # 650+ tests, no network, no Telegram
uv run ruff check . && uv run ruff format --check .
uv run mypy

The integration tests drive the real aiogram dispatcher with a fake Bot API session: updates go in, API calls come out, and the whole path — middleware, filters, callback data, parser, calculation, rendering — runs as it does in production. The fake can also fail on demand, so the suite covers what Telegram really does to a bot: a network error mid-reply, "message is not modified" on a repeated tap, a button whose message is gone, a sticker instead of text.

:godmode: Coverage says a line ran; it does not say a test would notice the line being wrong. So the logic modules (calc, money, parser, freshness, fx, models) are also mutation-tested with mutmut: 1,218 small deliberate bugs, each run against the suite. The first run caught 84%. The survivors were real holes — a salary exactly on a ceiling, a one-month horizon, a saving of exactly zero, the error naming the wrong word, no timeout on the ECB request — plus three pieces of dead code; all fixed. Now 96.6% are caught, and the 41 left alive are equivalent mutants (// vs / on positive numbers, zip(strict=…) on lists equal by construction, a temp file's name, the wording of a log line).

uv run mutmut run && uv run mutmut results

The mutation job in CI recomputes the score on Linux on every push to main and fails below 95%. On macOS, mutmut 3.8 reports some killed mutants as "segfault"; the figures above count those after re-running each one directly with pytest.



Contact

GitHub: vinter-man

Bot: @deel_hea_unofficial_bot

License: MIT. Unofficial, estimates only, not legal or tax advice.

About

Unofficial Telegram bot: full employer cost in 5 countries, EOR vs your own entity, and the break-even point. Python, aiogram, Pydantic.

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages