- Get Started
- Localhost
- Architecture
- Structure
- Pattern
- Page
- Layout
- Content
- Types
- Config
- Actions
- Queries
- Authorization
- Validation
- Form
- Table
- Detail
- Card
- Util
- Hooks
- List Params
- Views
- README.md
- ISSUE.md
- Technology Stack
- Database
- File
- CDN Assets
- Entry Points
- Dashboard
- Authentication
- Credentials
- OAuth
- Flow Diagrams
- Multi-Tenancy
- Offline
- Onboarding
- Onboarding Videos
- Add Values
- Admission
- Application
- Attendance
- Compliance
- Profile
- Exams
- Exam Wizard
- Timetable
- Classrooms
- Notifications
- Conference
- LMS (Lumos)
- Finance
- Fee Management
- Invoice
- Wallet
- Salary
- Payroll
- Timesheet
- Expenses
- Budget
- Receipt
- Accounts
- Banking
- Reports
- Dashboard
- Permissions
- Messages
- Integration Flow
- Provision
- AI Document Processing
- Document Intelligence
- Internationalization
- Translation
- Translation Guide
- Icons
- Docs Factory
- Inspiration
- Listings
- Teachers
- Students
- Catalog
- Library
- Contributing
- Code of conduct
- GitHub Workflow
- Database Seeds
- Database Safety
- Test Accounts
- Bulk import
- Playwright
- Prettier
- Block Rebound
- Transportation
The finance block is the largest feature in the platform (~45k LOC, 14 sub-modules) and the financial backbone of every school: it bills families, collects payments, pays staff, tracks spend, and keeps a double-entry ledger. This page is the authoritative status map — each sub-module has its own page linked below.
What the finance block is
Fourteen sub-modules grouped around a shared double-entry accounting engine. Money flows in (fees, invoices, wallet top-ups), money flows out (payroll, expenses), and every school keeps its own chart of accounts, currency, and permissions. Every query is tenant-isolated by schoolId.
| Group | Modules |
|---|---|
| Revenue & value | Fees, Invoice, Wallet |
| People & pay | Salary, Payroll, Timesheet |
| Spend & planning | Expenses, Budget, Receipt |
| Ledger & banking | Accounts, Banking, Reports |
| Platform | Dashboard, Permissions |
On a phone
Below the md breakpoint the finance hub, the family view and the sub-pages follow the school dashboard's phone pattern: figures as one grey two-across panel, grey cards with pill buttons, lists whose amounts sit under the title. Fee structures, assignments, payments, fines, scholarships and salary structures open on a card grid on phones (the toggle switches to the table; ?view=table in the URL wins at any width). Tablets and desktops are unchanged.
Double-entry model
The engine lives at finance/lib/accounting/. posting-rules.ts translates a domain event into balanced journal lines; actions.ts writes them.
Domain event (payment recorded, salary approved, …)
→ posting rule maps it to debit + credit lines (debits == credits)
→ JournalEntry + LedgerEntry rows written, scoped by schoolId
→ rolls up into Reports (trial balance, P&L, balance sheet)
Honest status — only fee payments post to the ledger today. The engine defines six domain posting functions (postFeePayment, postFeeAssignment, postSalaryPayment, postExpensePayment, postInvoicePayment, postWalletTopup), but only postFeePayment has callers (the fees action plus the Stripe and Tap webhooks). The other five have zero callers — so salary, expense, invoice, fee-assignment, and wallet movements do not yet reach the general ledger, even though several module READMEs imply they do. This is tracked as a P0 in the engineering ISSUE.md.
Status matrix
Authoritative readiness. Mirrors finance/README.md and finance/ISSUE.md. Ledger is the honesty signal: 🟢 posts · 🟡 posts but not transactional · ❌ posting fn exists but unused · 🔗 consumes the ledger · ➖ not a money-mover.
| Module | Readiness | Ledger | Tests |
|---|---|---|---|
| Invoice | 90% | ❌ postInvoicePayment unused | 🟢 131 |
| Fees | 85% | 🟡 fee payments only | 🟡 13 |
| Budget | 85% | ➖ | ❌ |
| Receipt | 85% | ➖ | ❌ |
| Banking | 80% | ➖ | 🟡 5 |
| Dashboard | 80% | ➖ (trends are mock) | ❌ |
| Expenses | 80% | ❌ postExpensePayment unused | ❌ |
| Accounts | 75% | 🟢 engine home (fee data only) | 🟡 10 |
| Permissions | 75% | ➖ | ❌ |
| Reports | 75% | 🔗 reads ledger (fee-only data) | ❌ |
| Salary | 75% | ➖ | ❌ |
| Timesheet | 75% | ➖ | ❌ |
| Wallet | 75% | ❌ postWalletTopup unused | ❌ |
| Payroll | 65% | ❌ postSalaryPayment unused | ❌ |
Overall ≈ 79% (average of the readiness column).
Sub-modules
- Fees — fee structures, assignment, online (Stripe/Tap) + offline payments, installments, overdue cron, fines, scholarships.
- Invoice — invoice lifecycle, creation wizard, email, analytics. The most complete sub-module.
- Wallet — cashless stored-value wallets, top-ups, balances.
- Salary — per-teacher salary structures, allowances, deductions, increments.
- Payroll — pay-period runs, salary slips, approve → disburse workflow.
- Timesheet — staff time periods and entries; intended to feed payroll.
- Expenses — expense submission, approval workflow, categories, budget integration.
- Budget — budget planning, allocation, variance tracking.
- Receipt — expense-receipt upload with AI extraction (distinct from the fees payment receipt PDF).
- Accounts — chart of accounts, manual journal entries, general ledger; home of the accounting engine.
- Banking — bank accounts, transfers, reconciliation (Plaid / Dwolla).
- Reports — balance sheet, P&L, trial balance off the ledger.
- Dashboard — role-scoped KPI cards, charts, alerts, quick actions.
- Permissions — granular
FinancePermissionmanagement (12 modules × 7 actions).
Cross-cutting concerns
Multi-tenant isolation
Every finance query is scoped by schoolId via getTenantContext(). A missing schoolId here is worse than elsewhere — it corrupts a shared ledger across schools. This is the strictest tenant boundary in the platform.
Currency per school
School.currency (ISO 4217) drives all money formatting; never hardcode $ or SDG. finance/lib/format.ts exposes three formatters and the amount-storage convention is mixed, so know which your data uses:
formatMoney(value)— value is in whole units (used by dashboard aggregations)formatCurrency(value)— value is in cents / 100 (used elsewhere)formatCompactMoney(value)— whole units, abbreviated above 10,000. The abbreviation is the locale's own word, not a letter: English readsSDG 10.6m, Arabic reads١٠٫٦ مليون ج.س.
Which one on which surface. Summary tiles — the KPI cards at the top of the finance hub, fees, salary, invoice analysis and banking — use formatCompactMoney, because a seven-figure total printed in full overflows the card and is read at a glance anyway. Everything a person reconciles against — tables, ledgers, invoices, receipts, payment and fine rows — keeps the exact formatter. Amounts below 10,000 render in full either way, so small schools never see an abbreviation.
Payment gateways
Resolved per school by country/timezone in src/lib/payment/gateway-config.ts; each gateway implements the PaymentProvider interface in src/lib/payment/.
| Gateway | Status | Markets |
|---|---|---|
| Stripe | Live | Global (Visa, MC, Apple Pay, Google Pay) |
| Tap | Implemented; activates with TAP_SECRET_KEY | Gulf (mada, KNET, STC Pay, Apple Pay) |
| Cash | Functional (returns instructions) | All |
| Bank transfer | Functional (returns bank details) | All |
| Mobile money | Functional (returns instructions) | Sudan (Bankak, mBOK) |
| Bankak | Stub — awaiting Bank of Khartoum API | Sudan |
The interface today declares only supportsCurrency, isConfigured, and createCheckout — there is no verifyWebhook or createRefund. Webhooks are handled per-gateway under src/app/api/webhooks/; the only refund path is Stripe course-enrollment refunds.
Accounting engine
See Double-entry model above and the Accounts page. Posting rules are append-only — add new rules rather than editing existing ones, or historical balance sheets shift.
i18n, RTL and the lang gap
UI strings flow through the dictionary system, notifications dispatch via the school's preferredLanguage, and since 2026-08-15 the two fee crons read finance.notifications.* too (no isAr ? … : … left). Server actions return error codes, and clients resolve them with actionErrorMessage(code, dictionary, fallback) — never a raw SNAKE_CASE code, never an exception message. Two ratchets keep it that way: the platform hardcoded-string ratchet, plus a finance-specific one (scripts/finance-i18n-audit.ts) that catches the class parity tests cannot — a d?.key || "English" lookup whose key does not exist in either language file, which silently renders English on /ar (28 of those shipped in the offline-payment form and the reports hub before the ratchet existed) — and bare English JSX text.
DB-stored text is the remaining debt. No finance Prisma model has a lang field, so Fine.reason, Scholarship / ExpenseCategory / ChartOfAccount names and FeeStructure name/description are stored in one language. Every finance list and report now routes them through the batched translation helpers (getLabels for labels, getNames for people — one resolution per list, transliteration fallback when the API is down), so a school's viewers see them in their locale once the translation cache is warm; a proper lang column is still the long-term fix.
RTL: the block uses logical utilities only (ms-/me-/text-start), navigation arrows carry rtl:rotate-180, and dir="ltr" appears only on IBAN / URL / key inputs. All 39 routes were verified rendering dir="rtl" on /ar.
What a family sees at /finance
/finance is two different pages depending on who opens it. For an admin or an accountant it is the hub above: school-wide revenue, expenses, payroll, gated on the reports permission. A student or a guardian holds none of that permission, and until 2026-09-09 got the deny screen on the one page named after their own money.
Since then the route forks by role before the gate is reached. A family gets its own money surface, built phone-first because that is where a parent opens it when a reminder arrives:
- What is owed, with the overdue part named separately, and one button to pay.
- The instalments still to pay, oldest due date first, each carrying the invoice number that proves it and a link to the hosted invoice where the school published one.
- Each fee with its full schedule and how far through it the family is.
- The ways this school actually takes money — the rails it has configured, wallet rails included, with the cash and bank-transfer options it settles at the office.
- Receipts for everything already paid.
Instalments are read from the invoice ledger rather than from the fee structure's schedule: the school issues one UserInvoice per scheduled payment at enrollment and the payment allocator settles them oldest-first, so those rows are what is actually owed rather than what was once planned. A past-due unpaid invoice reads as overdue immediately, without waiting for a cron to relabel it.
One qualification the page states in words: paying online settles a fee's whole remaining balance, not the single instalment the button sits beside. The checkout action takes no amount. Per-instalment charging needs that amount threaded through both the checkout and the webhook that allocates the payment.
RBAC
Role defaults, refined by the granular FinancePermission model and enforced by checkCurrentUserPermission() before every mutation.
Pages resolve their gate through finance/guard.ts: resolveFinanceAccess(module, actions) reads the session and tenant once, then evaluates every action concurrently. Every finance overview page now uses it; a chain of checkCurrentUserPermission calls is the anti-pattern it replaced, where gating on five actions serialized five session reads and five identical user lookups. Underneath, the caller's row and the granular grants for a module are each memoized per request with React cache(), and a role that already has finance access short-circuits before the grant table is touched at all.
Coverage is audited, not assumed. On 2026-08-15 all 80 finance route pages were traced to their database calls: the five fees list pages (structures, assignments, payments, fines, scholarships) fetched their first page with only a tenant check — a teacher or student could list every family's payments — and the subscription-portal page minted a school-scoped billing token for any signed-in user. Both are closed; every module page renders the block's inline deny UI to a role without permission (verified in a browser as a teacher), and a guard-audit ratchet fails the build if a new database-querying page appears without a gate (the four it still lists are own-data pages: a user's own bank accounts and own payslips, which are session-scoped rather than module-gated).
| Role | Access |
|---|---|
| Admin | All 14 sub-modules, permission management, accounting config |
| Accountant | Full finance incl. journal entries, payroll, reports |
| Developer | Platform-wide across all schools |
| Teacher / Staff | Own salary + payslips, submit expenses, clock in/out |
| Student / Guardian | View + pay fees, wallet balance, download receipts |
Schema
Finance models span several files under prisma/models/: finance-core (chart of accounts, journal, ledger, fiscal year), finance-fees (structures, assignments, payments, fines, scholarships), finance-payroll (salary, payroll runs, slips, timesheets), finance-banking (accounts, transfers, Plaid/Dwolla), finance-budgets (wallets, budgets, expenses), finance-invoices, and finance-reports. All models carry schoolId; none carry lang (see above).
Known gaps & roadmap
- Wire (or honestly retire) the 5 orphaned posting functions; make
postFeePaymenttransactional. - Feed the dashboard KPI sparklines from the monthly series the hub charts now use (
finance/lib/monthly-series.ts); the random-walk trends are gone. - Payroll tax is a hardcoded flat 15% — needs real brackets (its
config.tsalready ships unused ones). - Wire the invoice PDF (the fees receipt PDF already works); add payslip PDF.
- Add
langto user-facing finance models; route throughgetText. - Add
createRefund/verifyWebhookto the payment interface; finish Bankak. - Test coverage: 11 of 14 sub-modules have none.
The verified, file:line-cited register lives in finance/ISSUE.md, with phased payment work tracked in GitHub #263 (payment umbrella) and #264 (fee auto-provisioning).
Key files
| Concern | Path |
|---|---|
| Accounting engine | src/components/school-dashboard/finance/lib/accounting/ |
| Money/date format | src/components/school-dashboard/finance/lib/format.ts |
| Finance RBAC | src/components/school-dashboard/finance/lib/permissions.ts |
| Payment providers | src/lib/payment/providers/ |
| Gateway selection | src/lib/payment/gateway-config.ts |
| Fee ↔ invoice sync | src/lib/fee-invoice-sync.ts |
| Schema | prisma/models/finance-*.prisma |
| Tenant context | src/lib/tenant-context.ts |
See also
- Fees — the revenue entry point
- Invoice — billing lifecycle
- Multi-tenancy — how
schoolIdisolation works - Internationalization — the dictionary +
langmodel - Database — Prisma schema conventions