Kilimo Hakika Coporate Website
A complete website rebuild: custom CMS, product catalogue, and M-Pesa payments
Technical Consultant, Infrastructure and Software Development · Jul 2026 - Sep 2026 · Kilimo Hakika Limited

The whole site, not a storefront bolted onto one
Kilimo Hakika had a WordPress site that had drifted into the usual state: slow, awkward to edit, and structurally unable to do the one thing the business actually needed, which was sell feed.
This is a replacement for all of it. Thirteen pages covering the company, its services, its project engagements, the product catalogue, careers with real job listings and CV upload, a photo archive, videos, success stories and training resources. Every one of them is a real page with its own metadata and its own content, not a template with the words swapped.
The interesting constraint on the content side was that none of it could be hard-coded. Not the hero copy, not the service descriptions, not the section headings. Thirteen pages and forty-two content blocks live in the database as structured JSON, and the admin panel edits all of them. The company can rewrite their own positioning without opening a ticket, which is the difference between a site that stays current and one that quietly rots.
Then, on top of that, the commercial half: a catalogue of nine products across two categories, a quote workflow, and a way to take money.
The pricing rule that shaped the commercial half
Feed prices move, and they move per customer. Kilimo Hakika sells fish and poultry feed in bulk, and a published price list would have been wrong the day it shipped and awkward the day a distributor asked for volume terms. So the brief carried one hard rule: no public prices, ever.
That single constraint drove the whole architecture. Products carry names, images, specifications and pack sizes, and nothing else. Price exists in exactly one place, inside a quote, visible only to sales staff and to the one customer that quote was written for. A customer browses the catalogue, requests a quote in their own words (200 bags of 25kg), and sales builds a priced quote against it. That quote gets a reference, an expiry date, and an unguessable token. The customer opens it at a URL nobody can enumerate, sees their line items, and pays.
Paying by M-Pesa, without trusting the callback
Payments run on Safaricom's Daraja API directly, not through Pesapal or Paystack or any reseller. STK Push: the customer types their phone number, their handset prompts them, they enter their PIN.
The interesting problem is that Daraja's callbacks are not signed. Safaricom POSTs a payload to your webhook and there is no signature to verify it against, which means anyone who learns the URL can claim a payment succeeded.
So the webhook never trusts what it receives. It uses the callback purely as a hint that something happened, looks up the pending payment attempt by its CheckoutRequestID, then independently calls Safaricom's STK Query API to ask what actually happened, and only then marks anything paid. The amount is checked against the attempt too.
That confirmation path has three outcomes rather than two. Success and failure are obvious. The third is inconclusive: STK Query can itself error, or report that the transaction is still processing if it is polled too early. Treating that as a failure would mark a real payment as failed, so an inconclusive result leaves the attempt pending and lets the next poll decide.
The bug I caught before it could take someone's money
The first draft of the confirmation function sent the receipt email inside the same database transaction that marked the payment paid.
Two things wrong with that. Prisma's interactive transactions time out after about five seconds, which an SMTP round trip will not reliably fit inside. Far worse: if the email provider hiccuped, the transaction would roll back, and it would roll back a payment M-Pesa had already collected. The money would be gone and the system would believe the quote was still unpaid.
I caught it during plan review rather than in production. The rule I took from it: anything inside a transaction should be a database write, full stop.
A second one surfaced in the same pass. The audit-log helper was calling the global database client instead of the transaction client it had been handed, so an audit row could survive a transaction that later rolled back. The log would have recorded a state change that never happened.
Money, and not using floats for it
Every monetary column is a Postgres decimal, and every calculation goes through a small wrapper around Prisma's Decimal type. Money never touches a JavaScript number anywhere in the codebase. Daraja itself wants whole shillings with no decimal component, so the rounding happens once, deliberately, at the boundary.
Who can touch what
Three roles behind the admin panel. Editors handle content, sales handle quotes and payments, admins handle everything including user management, with a guard that will not let the last active admin demote or deactivate themselves. Passwords are argon2id. Every quote and payment mutation writes an audit row.
One small piece I am fond of. Images that appear in fixed positions, the home hero, the section teasers, the press clippings, used to be picked by index into the gallery table, which meant reordering the gallery silently changed the home page. They are now named slots. A slot with no row falls back to a baked-in default, so the page can never render blank on a fresh database.
Media, and the bucket that holds CVs
Every upload runs through sharp three times, producing 400px, 800px and 1600px WebP variants, all pushed to S3-compatible storage.
Job applicants upload CVs through the careers pages, and those do not go in the public bucket. They go in a separate private one, and the admin downloads them through presigned URLs that expire after five minutes. Applicant CVs are personal data and a public-read bucket is the wrong default for them.
Self-hosted, deliberately
This runs on the company's own Contabo VPS rather than Vercel. Postgres, MinIO and the app are all unpublished on an internal Docker network, with Caddy the only thing facing the internet and handling TLS automatically. Nightly Postgres dumps, weekly media snapshots, 30-day retention. I ran a restore to confirm the backups actually restore rather than assuming they would.
The build took some working out. Next's build step prerenders pages that call the database, and a Docker build has no database to reach, so it failed on the careers page and the sitemap. Setting an internal build network looked like the fix, but BuildKit's default driver rejects it outright. The answer was to stop building inside the Docker build at all: the build now runs as an ordinary Compose container, which can reach any other service by name, writing its output to the host filesystem for the runtime image to package.
What is tested
The money paths. Twenty-two unit and integration tests over the decimal arithmetic, the quote state machine and the payment confirmation function, including idempotent replay and amount mismatch, most of them against a real Postgres rather than a mock.
Then a Playwright test that runs the whole thing end to end: request a quote in a browser, build it, open the tokenized payment page, submit a phone number, and then act as Safaricom by POSTing a crafted unsigned callback at the real webhook, confirming the polling UI reaches "payment received".
The CMS and admin CRUD are verified manually and the verification is written down rather than automated. That is a deliberate allocation, not an oversight. The tests sit where the money is.
Honest status
The Daraja integration is complete and verified end to end against Safaricom's sandbox and a mock gateway. It has not yet processed a live payment, because the production shortcode and passkey arrive at go-live. No code changes are needed to switch over.
Lighthouse gives it 100 on SEO, accessibility and best practices. Performance sits between 66 and 86 on mobile against a target of 90, which I have left flagged as an open item rather than quietly marking it done.