The problem with loan management in cooperatives
In most member-led finance organisations, loan requests still arrive on paper forms or through informal WhatsApp threads. A credit officer manually transcribes the request into a spreadsheet, another person approves it verbally, and the record only makes it into a ledger after disbursement — if at all. There is no single view of the pipeline, no audit trail of who approved what, and no easy way for a member to check the status of their own application.
The Palladium AI loan management module was designed to close that gap. It gives members a self-service channel to apply, gives credit officers a live review queue, and gives administrators a full audit trail — all within the same multi-tenant workspace.
Core concepts
Loan record
Every loan application is stored as a document in the loans table. Each record captures the applicant's name, the requested amount, the team it belongs to, a source identifier for idempotent imports, and a status field that drives the workflow.
Loan status lifecycle
A loan moves through three explicit states:
pending— Submitted, awaiting reviewapproved— Approved by a team adminrejected— Declined by a team adminAll state transitions are persisted immediately via a Convex mutation and reflected in the UI without a page reload.
Team scoping
Every loan is attached to a specific team. Queries are always filtered by teamId, so members of one cooperative can never see loan records belonging to another. This isolation is enforced at the backend query layer, not just in the UI.
Role-based access
The module exposes different capabilities depending on the authenticated user's role within the team:
| Role | Apply for a loan | View all loans | Approve / Reject |
|---|---|---|---|
| member | Yes | Yes (team scope) | No |
| admin | Yes | Yes (team scope) | Yes |
| platform_owner | Yes | Yes (all teams) | Yes |
These checks are duplicated in both the UI (to hide or show the approve/reject controls) and in the Convex mutation handlers (to enforce them server-side regardless of what the client sends).
Applying for a loan
Any authenticated team member can open the loan application dialog from the credit desk page. The form captures a single input: the requested loan amount. On submission the frontend calls the loans.apply mutation which:
- Verifies the caller is authenticated.
- Confirms team membership (platform owners bypass this check so they can test from the admin console without joining every team).
- Validates that the amount is a finite positive number.
- Resolves the applicant's display name from their user record so the credit officer sees a real name rather than an opaque user ID.
- Inserts the loan with status
pendingand timestamps for bothcreatedAtandupdatedAt.
Because Convex queries are reactive, the new application appears in every connected browser session on the team instantly — no polling required.
The credit officer review queue
The credit desk page (/dashboard/[teamId]/loans) renders a live table of all applications for the team, ordered newest first. Three summary cards at the top give a quick snapshot:
- Total applications — the full count of records in the team.
- Pending review — applications still waiting for a decision.
- Approved — loans that have been greenlit.
Each row in the table shows the applicant name, the requested amount, a colour-coded status badge, and — for admin users — inline approve and reject action buttons. Clicking either button fires the loans.updateStatus mutation and the badge updates immediately in the UI.
Data model in detail
The loans table schema exposes every field that drives the module:
| Field | Type | Purpose |
|---|---|---|
| teamId | string (team ID) | Scopes the record to one workspace |
| sourceId | string | Unique key for idempotent legacy imports |
| firstName / lastName | string | Resolved from the user's display name |
| loanAmount | number | Requested principal in local currency units |
| status | string (optional) | pending | approved | rejected |
| createdAt | number | Unix ms timestamp of application |
| updatedAt | number | Unix ms timestamp of last status change |
Two indexes are defined: by_team for efficient per-team list queries and by_source_id to support the legacy data migration pipeline which upserts records from the previous Supabase-backed system without creating duplicates.
Legacy data migration
For cooperatives migrating from a previous system, the module includes a protected loans.upsertFromLegacy mutation. It accepts an array of loan rows and a migration token, then inserts or updates each record using sourceId as the idempotency key. Running the migration script multiple times is safe — already-migrated records are patched in place rather than duplicated.
What's coming next
The current module covers the core apply-review-decide loop. The next iteration will layer in:
- Repayment schedules — structured instalment plans linked to each approved loan, with payment recording and balance tracking.
- Risk scoring — an AI-assisted model that surfaces a repayment probability score alongside each application to support credit officers.
- Disbursement records — a confirmation step after approval that logs the actual disbursement date, channel, and reference.
- Notifications — in-app and email alerts to applicants when their loan status changes.
- Reporting exports — downloadable CSV and PDF summaries formatted to match regulator templates used by SACCOs in East Africa.