A cashbook for a coworking and innovation hub in Kigali. The accountant records every franc in and out, with a date, budget line, payment method and receipt reference. The approver approves the spending before it counts, and can later see exactly what was entered, what changed and what is missing.
Two rules shape everything else:
- Nothing counts until it is approved. A pending entry appears in the ledger and in the approval queue, but it does not move the running balance and it does not appear in reports.
- Nothing is ever deleted. There is no delete route. An entry that should not stand is voided with a reason and stays visible, dimmed and struck through. Every change writes a row to an append-only audit log.
Amounts are in RWF (set with CURRENCY).
This README is for whoever runs the system. The two documents below are for the people who use it, and are worth handing to a new accountant or approver on their first day.
| Document | Covers |
|---|---|
| MANUAL.md | How the system works: the four roles, the five rules, the life of an entry, each screen, the monthly routine, and the messages people hit. |
| SCREENS.md | Every control on every screen — what each button does and which roles can use it. |
Both are also published as web pages under docs/. docs/manual.html and
docs/screens.html are built by node docs/build-manual.mjs and node docs/build-screens.mjs,
which inline the two webfonts so the pages carry no external dependencies. Run npm run build
first — the scripts read the font files that next/font fetched. Edit the matching
*.template.html, not the built output.
The stored role keys and the names people see are deliberately not the same. The keys are what
requireRole() checks and what sits in the database; renaming them would mean migrating every
existing user, so only the labels in ROLE_LABELS changed.
| Key in code and database | Shown in the interface | Can do |
|---|---|---|
accountant |
Accountant | Records entries; edits or voids their own entries while still pending; reads the ledger and reports; exports to Excel |
auditor |
Approver | Approves, rejects and voids; reads the full audit trail and the exceptions page; closes months; exports. Cannot record or edit entries |
admin |
Administrator | Everything an approver can do, plus managing people and budget lines |
viewer |
Auditor | Read-only: the ledger, reports, the audit trail and the exceptions page |
Separation of duties is enforced in the route handlers, not in the interface:
- Nobody approves their own entry. If the entry's
recordedByis the signed-in user, the approve and reject routes return 403. SetALLOW_SELF_APPROVAL=trueonly if one person really does have to do both jobs. - The
approverrole is deliberately blocked fromPOST /api/transactionsandPATCH /api/transactions/[id]. - The interface hides buttons a role cannot use, but every route checks again.
The Settings page is where budget lines, people and the monthly close live. Administrators see all of it; approvers see the monthly close, because closing months is their job; everyone can reach it to change their own password.
The interface carries UR Binary Hub branding: a deep blue primary (#123c7a) with teal
accents for the innovation side of the hub, a near-white canvas, navy text, rounded cards
with subtle shadows, a hand-drawn icon set and generous spacing. The brand gradient and the
faint circuit grid appear on the sidebar and the login pane.
Two rules survive from the earlier cashbook design because they are not decoration:
- Green and red only ever mean direction of money. Credit green (
#0f7a54) and debit red (#c0392b) are never used for emphasis, status or anything else. Teal fills the accent role they would otherwise be pulled into. - Every figure is tabular monospace and right-aligned, and the running-balance column keeps its own ruled, tinted lane. Digits that do not stack in a column are a real defect in a ledger, not a cosmetic one.
The tokens live in the @theme block in app/globals.css; nothing else hard-codes a colour.
Icons are hand-drawn inline SVG in components/Icons.jsx rather than an icon package.
This supersedes §10 of
BUILD.md, which specified a paper-and-ink cashbook aesthetic and forbade gradients, rounded cards with shadows and icons. That section was overridden deliberately in favour of the UR Binary Hub brand direction.
- Node 20 or newer
- A MongoDB Atlas cluster (or any MongoDB you can reach)
cp .env.example .env.local # then fill in MONGODB_URI and JWT_SECRET
npm install
npm run seed # add --demo for example data: npm run seed -- --demo
npm run devOpen http://localhost:3000 and sign in with SEED_ADMIN_EMAIL and SEED_ADMIN_PASSWORD,
then change that password from Settings.
npm run seed is safe to run repeatedly. It adds any missing budget lines, creates the
administrator if that email is not already registered, and never overwrites an existing
password. npm run seed -- --demo also creates an accountant and an approver and fills the
book with about forty entries across the last three months, including a few that trip the
exception checks, so every screen has something in it.
MONGODB_URI=mongodb+srv://USER:PASSWORD@cluster0.xxxx.mongodb.net/hub_ledger?retryWrites=true&w=majority&appName=Cluster0
JWT_SECRET=generate-with-openssl-rand-base64-32
CURRENCY=RWF
ALLOW_SELF_APPROVAL=false
LARGE_AMOUNT_THRESHOLD=100000
SEED_ADMIN_NAME=Hub Admin
SEED_ADMIN_EMAIL=admin@hub.rw
SEED_ADMIN_PASSWORD=change-me-on-first-login
The database name hub_ledger sits between the host and the ?. A connection string
without one silently writes everything to a database called test.
Generate the secret with openssl rand -base64 32. The app throws a clear error on the
first request if MONGODB_URI or JWT_SECRET is missing.
.env.local holds real secrets and is gitignored. .env.example is committed and holds
placeholders only.
- Set
MONGODB_URI,JWT_SECRET,CURRENCY,ALLOW_SELF_APPROVALandLARGE_AMOUNT_THRESHOLDin Vercel → Settings → Environment Variables, for all three environments (Production, Preview, Development). TheSEED_ADMIN_*variables are only needed on your own machine. - In Atlas → Network Access, allow
0.0.0.0/0. Vercel's serverless functions have no fixed egress IP, so an IP allowlist will fail in production. - Run the seed locally against the Atlas connection string once —
npm run seed— not on Vercel. - Cookies are set
httpOnly,sameSite: 'lax', andsecurein production.
The login rate limit (10 attempts per 15 minutes, keyed on email and IP) is held in an
in-memory Map. On serverless that map lives inside one warm instance, so the limit is
per instance, not global — a determined attacker spread across instances gets more
attempts than the number suggests. It is the right trade-off at this scale; if the hub ever
needs a hard limit, move the counter into MongoDB or Redis.
The ledger's Import a sheet button takes an .xlsx straight from Excel, Numbers or
Google Sheets, or a .csv. Download the template from the dialog, fill in one row per entry,
and upload it.
| Column | Required | Notes |
|---|---|---|
| Date | yes | dd/mm/yyyy, yyyy-mm-dd, or a real Excel date cell |
| Direction | yes | in or out (also accepts Money in / income / expense / credit / debit) |
| Amount | yes | 45000, 45,000 and 45 000 all work; no currency code needed |
| Budget line | yes | Must match an active budget line by name |
| Description | yes | Up to 300 characters |
| Paid to or received from, Method, Reference, Receipt link, Note | no | Method defaults to Cash |
Nothing is written until you confirm. The upload is first checked row by row against the same rules the entry dialog enforces — a real date, not in the future, a positive amount, a budget line that matches the direction, and a month that is still open — and the result is shown as a table before anything is saved. A sheet with any bad row imports nothing: a half-imported cashbook is harder to unpick than a rejected file. Up to 500 rows and 2MB at a time.
Imported entries land as pending like any other, so the approver still approves them one by
one, and each one gets its own audit row noting the file it came from.
The reader is in lib/xlsx.js — an .xlsx is a ZIP of XML parts, and Node can already
inflate and parse both, so it stays a dependency-free file rather than pulling in a library.
It reads values only; formatting, formulas and charts are ignored.
Two layers, following the INUMA integration guide: lib/mailer.js is the
transport and knows nothing about the ledger; lib/email.js holds the templates
and one function per event and knows nothing about SMTP. Swapping to another provider means
replacing mailer.js alone, as long as it still exports sendEmail / verifyMailer /
closeMailer.
The settings are built into the code, not read from the environment, because production
runs from an image where nobody can add variables. The defaults live in
lib/mailer.js (relay 10.20.61.125, From urbinaryhub@ur.ac.rw) and
lib/app-url.js (https://finance.urbinaryhub.rw, which every link in every
email is built from).
Nothing secret is being committed: the relay has no username or password, its address is an internal one that is useless from outside UR, and the From is a public mailbox.
Each can still be overridden by an environment variable where one can be set:
UR_SMTP_HOST=… UR_SMTP_PORT=25 # port defaults to 25
UR_MAIL_FROM=… UR_MAIL_FROM_NAME=…
FRONTEND_URL=… # overrides the built-in link originThe relay has no username or password — it accepts mail from trusted internal addresses,
so the transporter authenticates by where it runs. On a host outside that range every send
fails with a 5xx. Check with nc -zv $UR_SMTP_HOST 25, then npm run verify:email.
Because the relay is built in, a developer machine will try to reach it and fail with
Connection timeout — harmless, logged, and nothing else is affected. To see the messages
locally, point the app at a capture server with UR_SMTP_HOST=127.0.0.1 UR_SMTP_PORT=2525.
| Event | Who is told |
|---|---|
| Entry recorded, imported or batch-recorded | Approvers and administrators, except whoever recorded it |
| Entry edited after review, so back in the queue | Approvers and administrators, except the editor |
| Entry approved, rejected or voided | Whoever recorded it, with the reviewer's comment |
| Comment left on an entry | The recorder and the reviewer, except the commenter |
| Month closed or reopened | Approvers and administrators, except whoever did it |
| Account created | The new person — never with the password, which is handed over directly |
| Role changed, account deactivated or restored, password reset by an admin | That person |
| Own password changed | That person, as a security confirmation |
Two rules hold everywhere:
- A failed email never fails the operation. Sends go through
notify(), which catches everything and logs[email] notification failed: …. Verified by killing the relay mid-flight: the entry still saved,201in 1.8s. notify()does not block the response. An approval must not wait on a relay that hangs for ten seconds. The consequence is that on a platform which freezes the process the moment a response is returned, in-flight sends would be cut short. The Docker deployment is a long-running server, so they complete; the same caveat as the in-process login rate limit.
- The accountant records entries as the money moves, with a receipt reference or a link to a photo of the receipt on every expense.
- The approver clears the approval queue — ideally daily, at worst weekly. Nothing counts towards the balance until they do.
- The approver works through Exceptions: expenses with no receipt, entries edited after review, large amounts, backdated entries, possible duplicates, and anything that has been waiting more than a week.
- Reconcile the book against the bank statement and the mobile money statement, and count the cash in the drawer.
- Close the month from Settings: pick the month, enter the cash you physically counted, check the variance against the book balance, and confirm. Closing is refused while any entry in that month is still pending, and afterwards nothing dated inside that month can be created, edited, voided or approved. An administrator can reopen a month, and the reopening is logged.
One Next.js 16 project holding both the interface and the API, JavaScript throughout,
Tailwind CSS v4 configured in app/globals.css with no tailwind.config.js, MongoDB
through Mongoose, and a JWT in an httpOnly cookie. No UI component library, no charting library,
no icon package and no date library — bars are divs, icons are inline SVG and dates go
through Intl. The spreadsheet reader is hand-rolled too, so the dependency list is still
just mongoose, bcryptjs and jose.
lib/ db, session, audit, period, http, format, enums, queries, api
models/ User, Category, Transaction, AuditLog, ClosedPeriod
components/ Shell, NavLinks, Modal, Field, Money, LedgerTable, EntryDialog,
FilterBar, Bars, Figure, MonthPicker, ReviewActions, AuditTrail,
ExceptionSections, ReportView, SettingsPanels
app/ (app)/… screens, api/… route handlers, login
scripts/ seed.mjs
Pages are Server Components that read their data straight from lib/ rather than fetching
their own API. Interactive pieces — filters, dialogs, approve buttons — are 'use client'
components that call the API routes. lib/queries.js holds the read logic the pages and the
routes share, so the ledger you see and the JSON the API returns can never drift apart.
lib/enums.js holds the shared vocabulary so client components can import it without
dragging Mongoose into the browser bundle.
middleware.js runs on the Edge, where Mongoose cannot, so it only checks whether the
session cookie exists and sends signed-out visitors to /login. Verifying the token and
checking the role happens inside each route handler. (Next 16 prints a deprecation notice
suggesting proxy.js; the file is kept as middleware.js for now.)
The running balance is computed, never stored: GET /api/transactions returns the sum of
approved amounts before the range as openingBalance and walks the page in date order.
Because a running balance over a filtered subset would be meaningless, it is only computed
when dates are the only filter in play — otherwise the response says isLedgerView: false
and the column disappears.
No file upload storage — the receipt field holds a URL, and a Drive link is fine. No multi-currency, no double-entry accounts, no invoicing, no bank feed import, no email notifications. Each of those would be a reasonable next step, roughly in that order.