The last link in heat cost allocation is this: a person in a flat receives a bill and asks “where did this number come from?”
The place that answers is a portal. One existed, but it sat on a ten-year-old e-commerce codebase and every new requirement strained it further. I rewrote it: app.brunatazenner.tr.
What it shows
- Bills — line by line: heat share, common areas, water, fixed charges. Downloadable as PDF.
- A daily consumption chart — meter readings with the day’s outdoor temperature on top. Side by side, “the winter was cold” stops being a claim and becomes a graph.
- Comparison with the building average — was it you, or the building? Half the incoming questions end at this box.
- Requests — a simple ticket system with messages.
- Household sub-users — so a partner or child can log in on their own.
There is also a separate site manager panel: blocks and units, bulk SMS to residents, announcements, incoming requests.
The real architectural decision: PULL
Bills are not produced in the portal. They are produced in the CRM — period by period, in their own databases.
Two options:
| PUSH | PULL | |
|---|---|---|
| How | CRM sends to the portal when a bill is approved | Portal asks the CRM when it needs to |
| CRM must know the portal | yes | no |
| If the portal is down | the send is lost, you need a queue | nothing happens |
| Data drift | inevitable over time | the source is always right |
I chose PULL. When the portal needs a bill list it calls the CRM endpoint, takes the answer and writes it to its own table (cache-aside). Next time it reads from cache.
When the cache is refreshed it does “purge then insert”, not “upsert”. The reason: if a bill was cancelled in the CRM, an insert-only strategy would never remove it from the portal. The resident would keep seeing a cancelled bill for months.
The tenant-who-moved-out problem
This is the part I thought hardest about, and it is not a technical problem but an ethical one.
A tenant moves out in March. They must still be able to see February’s bill — they lived there, they paid it. But they must not see April’s; the flat is now somebody else’s and so is the consumption in it.
Two mechanisms:
relation: user ↔ unit
visible_from = date they moved in
visible_until = date they left ← nothing after this
relation_frozen = 1 ← never ask the CRM again
The visibility window decides which periods they can see. The frozen relation is stronger: the system never calls the CRM for that relation again, and shows only what was already cached.
Without the second, a bug or a mismatched record could let a former tenant see the new tenant’s data. With a frozen relation that is physically impossible — the query is never made.
Building authorisation as a show/hide filter is not enough. What is actually safe is never fetching the data. Someone will eventually forget a filter; nobody can forget a query that was never written.
The technology side
I used no framework, but I did not write everything either. Three pieces borrowed, the rest mine:
routing : nikic/fast-route
templates : Twig
database : PDO + a thin wrapper
PDF : mpdf (renders the bill from structured data, not a screenshot of HTML)
env : phpdotenv (secrets outside the web root)
The old database is used unchanged. Table names, prefixes, even the legacy password hashing were kept — users log in with their old passwords and are silently upgraded to a modern hash on first login. Not having to reset anyone’s password while replacing a portal is a bigger win than it sounds.
And a demo mode
The portal has to be shown in sales meetings. You cannot open a real resident’s bill.
So there is /demo: a full account — 24 months of consumption, three locations, realistic bills. All generated data, and the demo account never calls the CRM (the second use for the frozen-relation mechanism).
What I learned
The hard part of a portal is not the screens. It is who may see what — and that question is almost never solved by roles. It changes with time, with moves, with cancellations, with corrections.
Modelling visibility as a date range was, for me, the most valuable decision in this project.