Multi-tenant fintech SaaS platform for loan lifecycle management, double-entry bookkeeping, and configurable workflow automation.
Built as a Django modular monolith with a Next.js frontend. The problems worth solving here are not the CRUD: keeping tenant data isolated when any unscoped query could leak it, keeping books that balance by construction rather than by reconciliation, and letting a tenant rewire an approval chain without a deploy.
Where the design has known limits, they are named in Future Work rather than left for a reader to find.
FinCore gives organizations a full lending operation out of the box:
- Loan lifecycle — from application through multi-step approval, disbursement, and repayment tracking
- Double-entry ledger — every transaction creates balanced ledger entries; books never go out of sync
- Configurable workflows — approval chains defined in JSON, no redeploys required
- Audit trail — immutable, append-only log of every action with actor, diff, and IP
- Multi-tenant isolation — one deployment serves many organizations; data never crosses tenants
- Subscription billing — Chapa payment integration with plan-based feature gating
- In-app + email notifications — event-driven, user-configurable per channel and event type
Loan lifecycle — the portfolio view, and a loan opened to its stage timeline, schedule, and running balance.
Approval workflow — a submitted loan lands in the assigned approver's queue; approving it advances the instance to the next step.
Double-entry ledger — wallet statements trace back to journal entries, and the trial balance closes with debits equal to credits.
Audit trail — an append-only record of every mutation, with actor, entity, changed fields, and timestamp.
| Layer | Technology |
|---|---|
| Framework | Django 5.x + Django REST Framework |
| Database | PostgreSQL 16 |
| Cache & Events | Redis 7 (Redis Streams) |
| Async tasks | Celery 5 + Celery Beat |
| Auth | JWT via djangorestframework-simplejwt |
| Payments | Chapa (abstract gateway pattern) |
| Testing | pytest + factory_boy + faker |
| Containerization | Docker + Docker Compose |
| Layer | Technology |
|---|---|
| Framework | Next.js (App Router) + TypeScript |
| Styling | Tailwind CSS v4 (CSS-variable design token bridge) |
| Server state | TanStack Query |
| Client state | Zustand |
| Forms | React Hook Form + Zod |
| UI components | Custom design system (11 base + 6 domain components) |
FinCore follows a modular monolith organized around bounded contexts. Each domain module (saas, finance, workflow, audit, events, notifications, billing) owns its models, services, and API layer. The core/ package provides shared infrastructure used across all modules.
Request → JWT Auth → TenantMiddleware → IdempotencyMiddleware → DRF ViewSet
↓
Service Layer
↓
EventBus (Redis Streams)
↓
Celery Workers → Handlers
| Decision | Choice | Rationale |
|---|---|---|
| Multi-tenancy | Shared schema with tenant_id FK |
Simple to operate; proven at scale. Schema-per-tenant is a migration path, not day-one cost. |
| Ledger | Double-entry bookkeeping | Financial integrity guarantee — every transaction balances by construction. |
| Workflow engine | JSON-defined templates stored in DB | Tenants configure approval chains without deployments. |
| Event system | Redis Streams → Celery consumers | Decoupled, retryable, with a clear Kafka upgrade path. |
| Idempotency | Client-supplied Idempotency-Key header |
Stripe pattern — prevents duplicate disbursements and repayments. |
| RBAC | Custom Role + Permission, tenant-scoped |
Granular (loan:approve), decoupled from Django's auth, and fully auditable. |
| Payment gateway | Abstract PaymentGateway protocol |
Swap Chapa for Stripe by implementing one class. |
Full decision registry: docs/fincore_architecture.md
fincore/
├── backend/
│ ├── config/ # Django settings (base / dev / prod / test)
│ ├── core/ # Shared kernel: BaseModel, TenantManager, middlewares, decorators
│ └── apps/
│ ├── saas/ # Tenants, users, memberships, roles, permissions, plans
│ ├── finance/ # Loan products, loans, wallets, ledger, repayment schedules
│ ├── workflow/ # Workflow definitions, instances, step execution engine
│ ├── audit/ # Immutable AuditLog, @auditable decorator
│ ├── events/ # DomainEvent model, EventBus, Redis Streams consumer
│ ├── notifications/ # Notification model, InApp + Email channels, preferences
│ └── billing/ # Subscription, Invoice, PaymentRecord, Chapa gateway
├── frontend/
│ └── src/
│ ├── app/ # Next.js App Router pages (auth + dashboard routes)
│ ├── components/
│ │ ├── ui/ # 11 base components (Button, Table, Modal, Drawer, …)
│ │ └── domain/ # 6 domain components (AmountDisplay, LoanTimeline, …)
│ └── lib/ # API client, format utils, status utils, Zod schemas
├── docs/ # Architecture document, implementation plan, design system
└── docker/ # Docker Compose for full local stack
CREATED → SUBMITTED → UNDER_REVIEW → APPROVED → DISBURSED → ACTIVE → COMPLETED
↘ REJECTED ↘ DEFAULTED
Each transition is event-driven: submitting a loan fires loan.submitted, which triggers the configured approval workflow. Once all workflow steps pass, loan.approved fires and the engine automatically disburses funds to the borrower's wallet via double-entry ledger entries.
All endpoints sit under /api/v1/. The API is fully versioned and documented via OpenAPI 3.0 (drf-spectacular).
Each module mounts its own namespace, so the URL names the bounded context that owns the resource.
| Module | Mount | Key Endpoints |
|---|---|---|
| Auth | /api/v1/auth/ |
POST token/, POST token/refresh/, POST register/, GET PATCH me/, POST logout/ |
| SaaS | /api/v1/ |
tenants/, members/, roles/, permissions/, plans/ |
| Finance | /api/v1/finance/ |
loan-products/, loans/, wallets/, ledger/trial-balance/ |
| Workflow | /api/v1/workflow/ |
definitions/, instances/, steps/, my-tasks/ |
| Audit | /api/v1/audit/ |
logs/, logs/entity-history/?entity_type=&entity_id= |
| Notifications | /api/v1/notifications/ |
collection at the mount root, preferences/ |
| Billing | /api/v1/billing/ |
subscriptions/, invoices/ |
| Webhooks | /api/v1/webhooks/ |
chapa/ |
State transitions are modelled as actions on the resource that owns them, rather than as free-floating verbs. The loan is the aggregate root, so the lifecycle lives on it:
POST finance/loans/{id}/submit/ fires loan.submitted, starts the configured workflow
POST finance/loans/{id}/approve/ fires loan.approved, which triggers disbursement
POST finance/loans/{id}/disburse/ writes the balanced ledger entries
POST finance/loans/{id}/repay/ oldest installments first, split penalty/interest/principal
GET finance/loans/{id}/schedule/ amortisation schedule
GET finance/wallets/{id}/statement/ running balance with per-entry provenance
The same shape applies elsewhere — approvals act on the step, not on a global queue:
POST workflow/steps/{id}/action/ approve, reject or return the step
POST tenants/switch/ re-scopes the session to another tenant
POST roles/{id}/assign_permissions/ grants granular permissions (loan:approve, …)
POST members/invite/
POST billing/invoices/{id}/checkout/ opens a Chapa payment session
POST billing/subscriptions/{id}/change-plan/
The frontend is built on a custom design system with a CSS-variable token layer bridged into Tailwind v4. Every screen enforces three rules:
- Status Rail — 3 px left border on entity cards and table rows, color-coded by status
- Monospace numbers — all currency, IDs, and dates use
font-monoviaAmountDisplayorformatAmount() - Semantic status colors — always derived from
loanStatusVariant(), never hard-coded
Full spec: docs/ui_design_system.md
403 backend tests (pytest) plus 6 end-to-end flows (Playwright), run on every push via GitHub Actions.
| Area | Scope | Tests |
|---|---|---|
| Finance core | Ledger invariants, loan lifecycle, repayments, wallets | 135 |
| Workflow engine | Definitions, instances, step execution, approval chains | 75 |
| Billing | Subscriptions, invoices, Chapa gateway, webhook replay safety | 47 |
| Audit | Append-only guarantees, @auditable coverage, service wiring |
46 |
| Notifications | In-app and email channels, per-user preferences | 30 |
| Security hardening | Password complexity, security headers, field encryption, input validation | 25 |
| Events | Event bus, Redis Streams consumer, idempotent handlers | 23 |
| Multi-tenancy & RBAC | Tenant isolation, roles, permissions, JWT auth | 22 |
End-to-end coverage spans authentication, tenant switching, RBAC enforcement, and the full loan lifecycle.
Requires Docker, Docker Compose v2, and Node.js 22+.
# Clone and configure
git clone <repo-url> && cd fincore
cp .env.example .env # edit ENCRYPTION_KEY and optionally HOST_*_PORT values
# Start the full stack (Django + PostgreSQL + Redis + Celery)
docker compose -f docker/docker-compose.yml up --build -d
# Run migrations
docker compose -f docker/docker-compose.yml exec django python manage.py migrate
# Create a superuser (interactive)
docker compose -f docker/docker-compose.yml exec -it django python manage.py createsuperuser
# Seed a full demo dataset (creates its own accounts — no prior registration needed)
docker compose -f docker/docker-compose.yml exec django python seed_demo.py
# Rebuild the demo tenant from a clean slate, discarding any changes made to it
docker compose -f docker/docker-compose.yml exec django python seed_demo.py --reset
# Run the test suite
docker compose -f docker/docker-compose.yml exec django pytest
# Start the frontend (separate terminal)
cd frontend && npm install && npm run devThe API is available at http://localhost:8000/api/v1/ (or whatever HOST_DJANGO_PORT is set to) and the frontend at http://localhost:3000.
seed_demo.py builds a tenant called Demo Lending Co with something on every
screen: five staff accounts across distinct roles, six borrowers, eight loans
spanning the whole lifecycle (completed, active, in arrears, defaulted, awaiting
approval, rejected, draft), a balanced ledger with real repayments, two pending
approvals in My Tasks, notifications, invoices, and an audit trail attributed to
named people rather than to "System".
All seeded accounts share the password demo1234:
| Account | Role |
|---|---|
me@example.com |
Owner (also sits in both approval roles) |
officer@example.com |
Loan Officer |
analyst@example.com |
Credit Analyst |
finance@example.com |
Finance Manager |
auditor@example.com |
Auditor — read-only, audit:read |
Re-running without --reset is additive and idempotent, so it will not undo a
loan you advanced by hand. Use --reset to get back to the exact starting state.
Known limits of the current build. Most are deliberate scope cuts; two are loose ends found while hardening the demo path, recorded here rather than left for a reader to discover.
Tenant isolation is enforced in the application layer. TenantManager scopes every query by default, but objects_unscoped exists and is used in roughly seventy places where code legitimately runs outside a request (event handlers, Celery tasks, the seed script). One forgotten scope is a cross-tenant read. The next step is PostgreSQL row-level security as a second line of defence, so the database refuses the query even when the ORM would have allowed it.
Event delivery is at-least-once, and each handler carries the idempotency burden. A retry or a replay after a worker restart delivers the same event twice, and every handler has to recognise that independently — a duplicate-workflow bug from exactly this cause is fixed and regression-tested. Correctness should not depend on each handler author remembering: dispatch-level deduplication on (event_id, handler) would make it structural.
recover_pending_events is implemented but never scheduled. A celery_beat service runs in the compose stack, but no beat_schedule is registered, so the task that re-dispatches events stranded in PENDING — a worker dying between commit and dispatch — does not currently fire. It needs a schedule entry and an alert on pending-event depth.
Throttling is configured more thoroughly than it is applied. FinancialWriteThrottle and a financial_write rate exist, but DEFAULT_THROTTLE_CLASSES is empty and only the two auth token endpoints are actually throttled. Disbursement and repayment are the endpoints that most need it.
Redis Streams was chosen over Kafka. Right call at this size, and the consumer boundary is deliberately narrow to keep the swap cheap — but there is no long retention and no partition-ordered replay, so an event dropped past the stream trim horizon is gone.
Chapa is the only implemented payment gateway. The PaymentGateway protocol exists so a second is additive rather than invasive. That claim is unproven until a second one exists.
Reports are computed on read. The trial balance aggregates the entire ledger on every request. That is correct and fast at demo volume, and will not hold past a few million entries — period-close snapshots and materialised account balances are the standard answer.



