Enterprise / Public Sector
DMS-IS — Contract & Procurement Management
One system for contracts, framework agreements, suppliers, requisitions and reporting — built multi-tenant from the first migration, with data isolation enforced by the database itself rather than by developer discipline.
0
report areas, every one exportable to Excel
0%
tenant isolation enforced in the database
0h
worst-case data loss, with restore-tested backups
The problem
Procurement teams were running on spreadsheets, shared folders and paper trails. A contract lived in one file, its amendments in another, a supplier complaint in an email thread — and nobody could say, without an afternoon of digging, which contracts were about to expire or how much of a contract's value had actually been delivered.
Three things had to change: one source of truth for the whole contract lifecycle; delivery numbers tied to real invoices instead of estimates; and a hard guarantee that one organization's data can never surface in another's, because the same platform would serve several organizations.
What we built
Six workspaces that share one data model, so a supplier, a contract and a complaint are never copies of each other — they're linked records.
Contracts
Full lifecycle with line items, delivery tracking, amendments, complaints and documents on one record. Status — active, expiring soon, expired, fulfilled — is derived, never typed in.
Framework agreements
Umbrella agreements with a maximum value and duration. Contracts are raised from them and inherit the supplier.
Suppliers
A directory with tax-ID validation, a reputation flag, and a profile that rolls up every contract with that supplier.
Complaints & court disputes
Quality and quantity complaints with contractual sanctions, and disputes tracked by court and status — each linked back to its contract.
Requisitions
A role-based ordering workflow against successive-delivery contracts, from draft to recorded delivery.
Analytics
Charts, KPI cards and the table behind every chart, with one-click Excel export.
Contracts, tracked to the last invoice
A contract isn't a PDF — it's a promise being delivered against. Every contract carries its line items: what was ordered, in which unit, how much has shipped. Every delivery is logged as an activity with quantity, value, invoice number, invoice date and payment date, and the invoice itself attached.
Completion is calculated from those records on the server, per row. The progress bar in the register and the 100% on a contract page are the same number — and because nobody types it in, it can't drift from reality.
Framework agreements and the suppliers behind them
Framework agreements sit above contracts. Raise a contract from an agreement and the supplier is pre-filled and locked — the form says so — so a call-off can't quietly drift to a different vendor.
Supplier KPIs — contracts concluded, contracts fulfilled, value of fulfilled work — come from a dedicated aggregate query, not from the page of rows on screen. A paginated list would otherwise understate them, and the whole point of the card is to be right.
A paper trail for when things go wrong
Quality and quantity complaints and court disputes used to be the least visible part of a contract's life. Here they're first-class records, each linked to the contract it concerns.
Complaints get a “contractual sanctions” view that swaps the status column for the consequence — and the consequence field itself only appears while a complaint is unresolved. Court disputes are tracked by competent court and status: in progress, or finally resolved.
Requisitions: an approval workflow with rules
Requisitions are the most intricate module: organizational units order against successive-delivery contracts, a verifier approves, and headquarters records the delivery. Each step is gated by role, and each rule is enforced twice — the interface hides what you can't do, and the API refuses it independently.
- Each requisition snapshots the submitter's organizational unit at creation, so reorganizing the org chart never rewrites history.
- Only successive-delivery contracts can be ordered against. The form filters them out for convenience; the API rejects the rest on its own.
- An evidence view rolls requisitions up by organizational unit and unit of measure — kilograms and pieces are never added together.
- 01
Draft
Editable by its author
- 02
Submitted
Waiting for a verifier
- 03
Accepted
Approved, optional comment
- 04
Delivered
Headquarters records the delivery
Analytics that answer real questions
Six report areas — contracts, framework agreements, requisitions, complaints, court disputes and suppliers — each with KPI cards, charts and the table behind them. Contracts alone have eight views: overview, by year, by type, by value, by goods category, by place of delivery, quality and quantity, and by execution method.
Every report exports to Excel from exactly the data on screen, formatted in local number conventions. “Realized” means delivered in full; the quality and quantity view deliberately looks at all contracts, and ranks suppliers by how many quality complaints they've drawn.
Architecture
A Spring Boot 4 API on Java 21 behind a Next.js 16 / React 19 frontend. Keycloak handles sign-in and carries the tenant in the user's token; the API validates every token by introspection. PostgreSQL holds the data under 11 Flyway migrations, MinIO holds the documents, and Caddy terminates TLS in front.
- 01
Next.js frontend
React 19 and TanStack Query, with a server-rendered first paint
- 02
Spring Boot API
Java 21, role-checked endpoints, token introspection
- 03
PostgreSQL
Row-Level Security on every tenant table
Alongside
Keycloak
Single sign-on, roles and the tenant claim
MinIO
Contract, supplier and dispute documents
Caddy
Automatic TLS in front of the app and login
Isolation that fails loudly, not silently
In a multi-tenant system, one customer's data must never leak into another's. Instead of trusting every query to remember its tenant filter, isolation lives in the database: each time the API borrows a connection, the tenant from the user's token is stamped onto it, and PostgreSQL's Row-Level Security policies then filter every query on that connection — and fill in the tenant on every insert. Application code never carries the tenant at all.
The API connects as a restricted role that cannot bypass those policies; schema changes run under a separate, privileged role. And on startup the app scans the schema and refuses to boot if any tenant-scoped table is missing its policy — so a forgotten safeguard becomes a failed deploy, not a data leak discovered months later.
Running fully offline
One requirement broke the cloud playbook: the client needed to carry the whole system on a USB stick and run it on a single Windows machine with no internet connection, for a formal registration process.
Rather than fork the architecture, we packaged it. A script exports the five container images, a full copy of the repository and the configuration into one folder; a launcher script then brings up the identical stack on localhost. The only prerequisite is Docker Desktop, installed once.
Building it surfaced two real bugs that we fixed in the main codebase: Docker Compose derived image names from the folder name, so a copied package tried to rebuild from source — which needs the internet — and the demo seed data had quietly fallen behind recent schema migrations, so it failed on any fresh database.
Operations that don't depend on heroics
- Push to main deploys. GitHub Actions connects over SSH with a pinned host key — and fails the deploy if the server's identity ever changes — rebuilds only what changed, then checks the live site and the login service from outside.
- Encrypted, deduplicated backups every night — databases, identity store and documents — to off-site storage, with seven daily, four weekly and six monthly restore points. A full test-restore into throwaway containers matched production before we called it done.
- Tuned for a small server: capped JVM heaps, a smaller connection pool, request throttling and swap, all verified under load with container stats.
- A cloud firewall, a host firewall and fail2ban sit in front of an automatic-TLS reverse proxy. Development-only ports don't exist in production, because the production deploy excludes the override file that opens them.
Results
Live on its own production infrastructure since July 2026, with automated deploys, nightly restore-tested backups, and a portable offline edition running the identical application.
For the people using it, the answer to “which contracts expire soon, and how much has actually been delivered?” went from an afternoon of spreadsheets to a filter and a progress bar.
Product tour
More from the product
A few more screens from the interface. Click any image to enlarge it.
Sign-in on Keycloak
Single sign-on with a custom-themed login. The tenant travels in the user's token from this point on.
All data shown is seeded demo data.