How we built TaxJeje — tax compliance for every Nigerian taxpayer.
On the architecture, the trade‑offs, and what we learned shipping TaxJeje under the Nigeria Tax Act 2025.
The problem.
Nigerian tax compliance changed shape in 2025. The new Nigeria Tax Act collapsed decades of overlapping rules into a single progressive structure, replaced FIRS with the Nigeria Revenue Service, set five PAYE bands instead of the old eight, and — under the 2026 NRS enforcement schedule — required real‑time clearance on every B2B invoice before the seller could call it a real invoice. Every Nigerian who owes tax is now compliant under one law and one timeline.
Everyone, in practice, meant five distinct audiences who had never been served by the same tool. The salaried PAYE worker. The freelancer billing in dollars and pounds. The business owner running a registered LLC. The crypto trader logging fills from an exchange. And — coming up — the tax professional filing on behalf of dozens of clients at once. The off‑the‑shelf options each handled one. None handled all five.
We watched the existing tools try to retrofit. Foreign products assumed always‑on power and a salaried employee with one currency. Local products treated NRS clearance as a checkbox to tick rather than a system to design around. The accountancy‑firm products wanted you to hand the books over rather than work them yourself. None of them were built around the actual shape of Nigerian tax life — multiple currencies, intermittent connectivity, receipts collected over a year on a personal phone, a PAYE band a freelancer crosses in a single good month.
So we wrote down the question we were trying to answer. What does a tax‑compliance tool look like if it has to work for the salaried worker, the freelancer, the small business, and the crypto trader on the same Tuesday morning? TaxJeje is the answer we shipped. The rest of this piece is how we got there, what we got wrong on the way, and the decisions we'd make again.
The hardest part of building for Nigeria isn't the rules — it's the moments when the rules and the reality disagree.
The architecture.
TaxJeje is a Progressive Web App on the front, a Postgres database on Supabase in the middle, and a single daily cron behind the scenes. The whole stack is TypeScript, end to end — Next.js 16 with the App Router, Prisma 6 against Postgres, Zustand for the bits of state the URL can't carry, TanStack Query for everything that hits the network, Tailwind and shadcn for the components we didn't want to write from scratch. The choice was made on three grounds: it had to be readable by the next engineer we hire in Kaduna, it had to behave well on a mid‑range Android with two bars of signal, and it had to be operable by one person while we earned the right to hire a second.
The PWA decision came first and shaped everything that followed. Nigerian taxpayers do their taxes on phones — that's not a hypothesis, it's a measurement we took from the pilot. A native app would have meant App Store reviews, two codebases, and a story for why we'd ship to iOS at all when Android is most of the market. A PWA installed from taxjeje.com runs offline through a service worker, syncs when the network comes back, and gets updated the same day we push. By the second week of the pilot it was clear the right call: every user we talked to had at least one moment where their connection vanished mid‑edit, and the app kept working.
The data layer is conventional but deliberate. Income, expenses, invoices, receipts, and filings all live as ordinary Postgres rows. Multi‑currency is stored as posted values — every transaction carries the CBN rate of the day it was received, and the dashboard reads those stored rates instead of re‑converting on every page load. Once a rate is captured it doesn't move. Users stopped seeing their year‑to‑date numbers flicker; the dashboard stopped being a liar.
The NRS clearance flow is the most‑watched part of the system. The Merchant Buyer Solution exposes a SOAP‑over‑HTTPS endpoint that takes a UBL‑2.1 XML document, validates it against the 2026 schema, and returns an IRN and a signed QR. We wrap that endpoint in a typed TypeScript client and never let the rest of the app see XML. Invoices live as Postgres rows; the XML is generated only at the moment of submission, kept in the audit trail, and discarded from the hot path. The QR is rendered server‑side at submission time and cached against the IRN so the customer's PDF and the regulator's verifier are reading the same bytes.
Background work runs on a single Vercel cron, fired at six in the morning UTC. It hits /api/cron/dispatch, which fans out to eight internal jobs in parallel through Promise.allSettled — NRS clearance retries, CBN rate ingestion, the receipt‑OCR queue, filing‑deadline reminders, the AI assistant's nightly index refresh, and three more. Each job takes a Postgres advisory lock so two firings can't process the same row twice. The whole orchestration fits in one TypeScript file. We considered Redis, then a managed queue, then a workflow engine. Each adds a thing to operate. Postgres was already running.
The AI tax assistant deserves its own paragraph. It reads the user's current ledger state — what they've earned, what they've spent, what band they're in — and answers band‑aware questions in plain English. The model is constrained to quote only from the NTA 2025 bracket tables and the user's actual numbers; if the question falls outside that scope, it says so and offers to flag the case for human review. Hallucinated tax advice is the kind of mistake that ends conversations with regulators. Constraint is the feature.
1// The shape NRS cares about. Everything else is UI sugar.2export type Invoice = {3 id: UUID4 irn: string | null // null until cleared5 status: 'draft' | 'pending_clearance' | 'cleared' | 'rejected'6 currency: 'NGN' | 'USD' | 'GBP' | 'EUR'7 subtotalKobo: number // store in kobo; never floats8 vatKobo: number // 7.5% standard, 0% for exempt schedules9 buyerTin: string10 issuedAt: DateTime11 clearedAt: DateTime | null12 qr: string | null // base64, signed by NRS13}Decisions that mattered.
01Why we built for every Nigerian taxpayer instead of a niche.
The reasonable‑sounding voice in the room argued we should niche. Freelancers, agencies, USD earners — pick one persona, ship faster, sell easier. We picked all five instead: salaried, freelance, business, crypto, and the tax professional coming up behind them. The trade‑off was real — more schema, more research, more time to first ship. The pay‑off was that TaxJeje is the only tool that follows a Nigerian taxpayer from their salaried first job through to a registered LLC and a side bag in stablecoins without forcing them to change apps.
The discipline that made the wider scope tractable was a strict separation of the surfaces from the engine. Every income type — PAYE, contract, business revenue, on‑chain settlement — lands in the same normalised ledger. The UI screens differ; the math doesn't. The PAYE calculator that walks a salaried worker through five NTA 2025 bands is the same calculator that quotes a freelancer their next‑contract liability. Building it once, instead of forking per persona, is what kept the team to two engineers.
We'd do it again. A tool that follows a user across years is a tool a user tells their friends about. A niche tool is a tool the user grows out of.
02Why the AI tax assistant came before the analytics module.
We had a longer backlog than we had time. A reasonable‑sounding voice argued for analytics first — charts, breakdowns, the kind of dashboard a founder shows an investor. We built the AI tax assistant instead.
The Nigerian taxpayer's question is not show me my year‑to‑date in a chart. It is will I owe more tax if I take this contract? Can I deduct the new laptop? What changes if I move the business to a different state? An assistant that reads the ledger and the law in plain English answers those questions the moment they're asked. A chart can be derived from the same numbers later. The judgement can't.
Analytics will ship in the next quarter. We're glad we waited. A chart of made‑up numbers is worse than no chart at all, and we'd rather earn the right to draw one.
03Why the cron and queue are TypeScript against Postgres, not Redis or a managed workflow.
We tried Redis, briefly. The pitch is appealing: hand off retries, backoff, and dead‑letter handling to a hosted product, get on with the parts of the app that are actually our problem. The price you pay is that the queue lives somewhere you can't reach during an outage, billed in a way you can't predict, with a latency floor set by whichever region the vendor is operating from.
For TaxJeje, the queue is the product. If the IRN doesn't come back, the customer doesn't have a legal invoice. If the OCR queue stalls, receipts pile up. We needed to be able to look at every pending row, replay it, inspect why a worker failed, and explain to a regulator exactly what happened — without a third‑party dashboard between us and the answer. So we wrote the cron as a single TypeScript handler that fans out to eight jobs, each taking a Postgres advisory lock against the rows it's about to touch. The orchestration fits in a file we can read in under five minutes.
The trade‑off was real engineering time up front. We'd make it again. Owning the queue means we can ship a fix in an hour when NRS changes the rules — which they did, twice, in the run‑up to launch — instead of waiting for a vendor's roadmap.
A chart of made‑up numbers is worse than no chart at all. We'd rather earn the right to draw one.
1<Invoice xmlns="urn:oasis:names:specification:ubl:schema:xsd:Invoice-2">2 <cbc:ID>FJ-2026-000412</cbc:ID>3 <cbc:IssueDate>2026-03-04</cbc:IssueDate>4 <cbc:DocumentCurrencyCode>NGN</cbc:DocumentCurrencyCode>5 <cac:AccountingSupplierParty> <!-- TIN, address --> </cac:AccountingSupplierParty>6 <cac:AccountingCustomerParty> <!-- buyer TIN --> </cac:AccountingCustomerParty>7 <cac:TaxTotal>8 <cbc:TaxAmount currencyID="NGN">33750.00</cbc:TaxAmount>9 </cac:TaxTotal>10 <cac:LegalMonetaryTotal>11 <cbc:PayableAmount currencyID="NGN">483750.00</cbc:PayableAmount>12 </cac:LegalMonetaryTotal>13</Invoice>What we got wrong.
The first version of the receipt scanner shipped with too much confidence. We trained an OCR pass on a few thousand printed Nigerian receipts and assumed the long tail would behave. It didn't. POS receipts from one common terminal print the total in a font that the model read as N for 0 often enough to be a problem; in a particular failure mode an ₦18,000 receipt arrived in the cashbook as ₦18,N00 and the customer had to chase a phantom forty thousand naira. We had to walk three businesses through a manual reconciliation in the second week of the pilot. The fix was straightforward — a confidence threshold, a second model on disagreement, and a human‑review queue for anything ambiguous — but we should have shipped that posture from day one. We trusted the happy path on someone else's books, which is a luxury we hadn't earned.
The second mistake was smaller and more embarrassing. We built the dashboard's date picker to default to the Nigerian fiscal year — April through March — because that's what the tax filings use. Customers reading their dashboard on a Monday morning thought the numbers looked wrong, and they were right to: they were reading their year‑to‑date against a window that didn't match the calendar in their head. A two‑line change made the default the calendar year, and the fiscal‑year view became a toggle. The lesson was about defaults, not about dates. The default has to match the user's reflex, not the bureaucrat's.
Neither failure was structural. Both came from a habit we've since rooted out: assuming that a thing the team understood was a thing the customer would, too.
What we'd do again.
We'd build the offline queue first. Before the UI, before the dashboard, before the QR pipeline. It is the load‑bearing wall of the whole product, and starting with it forced every later decision to respect the reality that the network is unreliable and the regulator is unforgiving. Every team we've watched try to retrofit durability has paid more for it later than we paid up front.
And we'd keep the team small. Two engineers, fourteen weeks, one designer borrowing time from the marketing site. The work fit in two heads, which meant the trade‑offs — which feature we shipped, which we cut, which we shipped half of and called it that — could be made over a coffee instead of in a meeting. A bigger team would have shipped more screens; we'd have shipped less product.