Skip to content

Latest commit

 

History

33 Commits

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

FinCore

Backend CI Frontend CI Django PostgreSQL Redis Next.js TypeScript Docker

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.


What It Does

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

Demo

Loan lifecycle — the portfolio view, and a loan opened to its stage timeline, schedule, and running balance.

Loan portfolio and loan detail drawer

Approval workflow — a submitted loan lands in the assigned approver's queue; approving it advances the instance to the next step.

Approval inbox, reviewing and approving a loan

Double-entry ledger — wallet statements trace back to journal entries, and the trial balance closes with debits equal to credits.

Wallet statement and a balanced trial balance

Audit trail — an append-only record of every mutation, with actor, entity, changed fields, and timestamp.

Audit log filtered by action and entity


Tech Stack

Backend

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

Frontend

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)

Architecture

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

Key Architectural Decisions

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


Project Structure

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

Loan Lifecycle

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.


API Surface

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/

Design System

The frontend is built on a custom design system with a CSS-variable token layer bridged into Tailwind v4. Every screen enforces three rules:

  1. Status Rail — 3 px left border on entity cards and table rows, color-coded by status
  2. Monospace numbers — all currency, IDs, and dates use font-mono via AmountDisplay or formatAmount()
  3. Semantic status colors — always derived from loanStatusVariant(), never hard-coded

Full spec: docs/ui_design_system.md


Test Coverage

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.


Getting Started

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 dev

The API is available at http://localhost:8000/api/v1/ (or whatever HOST_DJANGO_PORT is set to) and the frontend at http://localhost:3000.

Demo data

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.


Future Work

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.

About

Multi-tenant fintech SaaS platform for loan lifecycle management, double-entry bookkeeping, and configurable workflow automation.

Topics

Resources

Stars

1 star

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages