A different kind of article

Nothing described here is built yet. No tenants table, no leases, no invoices, no payments. This is a design note written at the point where the next decision has to be made, published deliberately before the code exists so that it can be checked against whatever actually ships.

Writing it down first is the useful part. A justification composed after the fact always sounds correct, because the author already knows how the story ends.

Where the chain stops

PropertyOS currently has seventeen tables, and the onboarding chain runs like this:

HQLead creates a landlord
  └─ landlord_admin provisioned, activation link sent
       └─ admin sets their own password, logs in
            └─ admin creates properties
                 └─ admin creates units     ← ends here

Every one of those tables is about places, and the people who administer them. A unit knows its rent_amount. Its property knows its billing_cycle and currency_id. So the system already knows what a unit should earn and how often it should ask.

What it has no way to express is who owes that money, from when, and whether it arrived.

The obvious next table is the wrong one

The intuitive move is a tenants table with a unit_id column. Person lives in unit. It reads naturally and it is wrong in a way that is expensive to undo once there is real data in it.

A tenant is a person. A tenancy is an agreement. They have different lifetimes, and almost every ordinary case separates them:

  • One person rents a flat and a parking space from the same landlord. A single unit_id on the tenant cannot say that.
  • One unit has many tenancies across the years. Putting the person on the unit means each new occupant overwrites the last, and with them the answer to "who owed rent in June 2025?"
  • A couple signs jointly and both are liable. That is a many-to-many relationship, not a column.

The money lives in the agreement rather than in the person: the agreed rent, the start date, the notice period, the deposit. A rent increase changes a contract. It does not change a human being.

So the shape is three tables, not one:

tenants         the person. Name, contact, address. Nothing about money.
leases          the agreement. unit_id, start and end dates, agreed
                rent, deposit, status.
lease_tenants   the join. Which people are party to which agreement.

The question that settles it

Was unit 3 occupied on 15 June, and by whom?

With a lease table that is one query against a date range. With tenants.unit_id it is unanswerable, because the only thing recorded is the current state. A system that cannot answer it cannot produce a rent roll, cannot calculate arrears, and cannot explain its own invoices.

units.rent_amount does not disappear. It becomes the asking rent, the figure offered when a new lease is drawn up. leases.agreed_rent is what this particular tenant actually pays. In practice those two numbers diverge immediately and stay diverged, and collapsing them into one means raising the rent quietly rewrites history.

An invoice is a snapshot, not a view

The same mistake has a more serious version one table further on.

The tempting design is an invoice that joins to the lease and reads the rent when it is displayed. But an invoice is a statement about a moment in the past. Once issued it must never change, even if the rent is raised, the tenant moves out, or the lease ends.

So an invoice copies what it needs at issue time rather than referencing it: the amount, the tenant's name, the billing address as they were that month, the period covered. Keep lease_id as a foreign key so you can navigate, but never read a displayed value through it.

Amounts stay integers in minor units, which is already the convention units.rent_amount follows. Floating point and money do not belong in the same sentence.

Where the domain stops being generic

Two German requirements change the schema rather than just the interface, and both are the kind of thing you only find out by having worked next to the problem.

Invoice numbers have to be sequential and gapless under GoBD. That rules out COUNT(*) + 1, which races under concurrency and leaves holes whenever a transaction rolls back. It needs a dedicated sequence per landlord, and a number assigned at issue rather than at draft, so that deleting a draft does not consume one.

Residential rent is exempt from VAT; commercial letting can opt into it. That means a tax rate on each invoice line, snapshotted like everything else, and a flag somewhere above it recording which treatment applies.

What makes this concrete rather than theoretical is that the published demo already contains both cases. Nürnberg Wohnbau is residential. Roth Gewerbepark is commercial. The distinction was seeded months ago for a different reason and turns out to be the exact modelling problem.

To be explicit about the boundary: this is a note about what the schema must be able to represent. The rules themselves are a question for a Steuerberater, and I am not one.

Payments are not a boolean

A payment is not an invoice with paid = true. That design survives until the first real month:

  • A tenant pays €800 against an €850 invoice.
  • A tenant pays €1,700 in one transfer covering two months.
  • A tenant overpays and the credit carries forward.
  • A transfer arrives with no usable reference and has to be matched by hand.

So payments are their own event, and the link between the two sides is an allocation:

payments              amount, received_on, method, the raw reference text
payment_allocations   payment_id, invoice_id, amount

An invoice's balance becomes total − SUM(allocations). "Paid" is then something the system computes rather than a flag somebody has to remember to tick, which also means a partially paid invoice is representable instead of being rounded to one of two wrong answers.

Staff accounts are a modifier, not a noun

This is the one where I am arguing against my own instinct, which was to build user accounts early because they are the most impressive thing to show.

Today landlord_admin is all-or-nothing: one account, full visibility into everything that landlord owns. Staff introduces two new dimensions simultaneously. Roles within a landlord, so a bookkeeper sees invoices but not tenant contact details. And scope below a landlord, so a caretaker is responsible for one property rather than all of them.

The second is the structural one. resolveLandlordIdForActor currently answers with a single landlord_id, or null for the platform operator. Property-scoped staff means that function has to return something richer, and every query that trusts its answer has to be revisited.

Which is exactly why it goes last. Permissions are permissions over something. Designed before tenants, leases, invoices and payments exist, the permission matrix is a guess, and each module that lands afterwards forces a retrofit. Designed after, the access model gets worked out once against a complete picture.

The honest counter-argument is that access control is the most distinctive work in this project and the most interesting thing to show a reader. That is true, and it is already covered by the separate actor systems and by impersonation. The portfolio does not need staff accounts to make the point, so the ordering can follow the engineering rather than the shop window.

The order, and what it buys

1. Tenants + Leases + lease_tenants     the missing link
2. Invoices                             needs a lease to bill against
3. Payments + allocations               needs invoices to allocate to
4. Staff accounts                       modifies all of the above

This is a chain with no parallel path, which is worth stating plainly rather than pretending the work could be split.

It does divide into useful milestones, though. After step 1 the demo can show occupancy. After step 2, a rent roll. After step 3, arrears. Each one is demonstrable on its own, which matters for a project whose purpose is partly to be looked at.

What is still undecided

Listing these rather than resolving them, because inventing a justification for a decision not yet made is the habit this article exists to avoid.

  • Can one lease cover several units? The flat-plus-parking case says probably yes, which adds a lease_units join and makes invoicing sum lines. Cheap to decide now, a migration to decide later.
  • Are invoices generated by a scheduled job or a button? A nightly job is closer to how this works in reality and reuses scheduling that already exists. A button is far easier to show someone.
  • Deposits. German deposits must be held separately from operating money and earn interest, which argues for a ledger of their own rather than a column on the lease.
  • Does a tenant ever get a login? That would be a fourth actor system with its own secret, session namespace and middleware chain. Current instinct is firmly no. The product is for the landlord.

What would make this wrong

The lease argument depends on leases genuinely having their own lifecycle. For a landlord with four units and tenants who stay a decade, tenants.unit_id would work fine for years, and the extra tables would be unused complexity.

I am taking the more structured route anyway, because the cost is asymmetric. Adding a lease layer later means migrating live tenancy data and rewriting every query that touched it. Carrying one extra join from the start costs very little by comparison, and insurance against the expensive direction is worth paying for.

If the next few articles describe something other than the order above, that will be visible, and the reason will be worth more than the plan was.

See it running: mypropertyos.de — two landlord logins are published so you can test the isolation claim yourself. Credentials are on the PropertyOS project page.