How we built TaxJeje — tax compliance for every Nigerian.
On the architecture, the trade‑offs, and what we learned building TaxJeje under the Nigeria Tax Act 2025.
TaxJeje opens to the public in December 2026. This piece is about how it was built.
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 six progressive personal income tax bands with the first ₦800,000 of annual income exempt, and — under the 2026 NRS enforcement schedule — brought B2B invoicing into a central clearance system. 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 built. 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, and 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. The case we designed against is the connection that vanishes mid‑edit — so that is the case the architecture is built around, rather than one it degrades into.
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 exchange 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 e‑invoicing seam is the most‑watched part of the system — and the one we deliberately built to stop short of the wire. The Merchant Buyer Solution takes a UBL‑2.1 XML document, validates it against the 2026 schema, and returns an IRN and a signed QR; actual clearance has to transit an accredited Access Point Provider, so we built the seam to prepare, not stamp, until that accreditation is in place. We wrap the shape in a typed TypeScript client and never let the rest of the app see XML. Invoices live as Postgres rows; the XML is generated, validated, and staged at the moment of submission and kept in the audit trail. The IRN and QR handling are wired and tested against the spec — ready to go live the day the clearance channel is connected.
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 — the e‑invoice preparation queue, exchange‑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 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 six 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 scope tractable at all.
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, 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>The failure modes we built against.
The receipt scanner is the part of the system we trust least, by design. An OCR pass over printed Nigerian receipts handles the common case well and the long tail badly: POS receipts from some terminals print the total in a font where N and 0 are near‑identical, which is how an ₦18,000 receipt becomes ₦18,N00 in a cashbook. A parser that is quietly wrong about a number is worse than one that refuses to guess — the error compounds through the ledger and surfaces as a figure nobody can account for. So the scanner runs a confidence threshold, puts a second model on disagreement, and routes anything ambiguous to a human‑review queue instead of writing it to the books. That posture cost more up front than trusting the happy path would have. On someone else's books, the happy path is not a luxury we think we have earned.
The second is smaller and easier to get wrong. The dashboard's date picker wants to default to the Nigerian fiscal year — April through March — because that is the window the tax filings use. It reads as wrong anyway: someone checking their year‑to‑date is comparing it against the calendar in their head, not the one in the legislation. The default is the calendar year and the fiscal‑year view is a toggle. The point is about defaults rather than dates. A default has to match the user's reflex, not the bureaucrat's.
Neither is structural. Both come from the same habit worth designing out: assuming that a thing the people who built it understand is a thing the person using it will understand 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. The trade‑offs — which feature ships, which gets cut, which ships half‑built and is described that way — are better made in a conversation than in a meeting. A bigger team would have built more screens; it would not have built more product.