KYC — Know Your Customer¶
Overview¶
KYC (Know Your Customer) is an identity-verification gate that all Mukando cycle members must pass before they can participate. It is not required for Majana (expense-sharing) cycles.
The goal is lightweight fraud deterrence at zero cost: users submit their full legal name, an optional government-issued document photo, and an optional selfie. An admin reviews the submission and either approves or rejects it. Admins can also bypass the check entirely when they already know the person.
Statuses¶
KycStatus lives on the User entity and drives all enforcement logic.
| Value | Int | Meaning |
|---|---|---|
NotStarted |
0 | Default. User has never submitted. |
PendingReview |
1 | Submission received; awaiting admin review. |
Verified |
2 | Admin approved — user can join Mukando cycles. |
Rejected |
3 | Admin rejected — user must resubmit. |
AdminBypassed |
4 | Admin manually cleared the user without a document review. |
Verified and AdminBypassed are the two states that satisfy all KYC gates.
ID Types¶
| Value | Int |
|---|---|
NationalId |
0 |
Passport |
1 |
DriversLicense |
2 |
NoDocument |
3 |
When NoDocument is selected, IdNumber and the document photo are both optional. The admin still reviews the submission and decides whether to approve based on the name and selfie alone (or a side-channel trust).
Data Model¶
User (modified fields)¶
| Field | Type | Notes |
|---|---|---|
PhoneNumber |
string? |
Max 30 chars. Set at signup or via profile update. |
KycStatus |
KycStatus |
Default NotStarted. Updated whenever a review/bypass is recorded. |
UserKycDocument¶
File: server/src/Batanai.Api/Models/UserKycDocument.cs
A user can have multiple records — each rejected submission is retained for audit. Only the latest record drives User.KycStatus.
| Field | Type | Notes |
|---|---|---|
Id |
int |
PK |
UserId |
int |
FK → Users (cascade delete) |
IdType |
KycIdType |
Enum — document type selected |
IdNumber |
string? |
ID number as printed. Null for NoDocument. |
FullNameOnId |
string |
Legal name. Required for all paths. |
DocumentFileId |
int? |
FK → UploadedFiles (set null on delete). Photo of document. |
SelfieWithIdFileId |
int? |
FK → UploadedFiles (set null on delete). Selfie holding document. |
Status |
KycStatus |
Current state of this submission record. |
SubmittedAt |
DateTime |
UTC timestamp of submission. |
ReviewedByUserId |
int? |
FK → Users (set null on delete). Admin who reviewed. |
ReviewedAt |
DateTime? |
UTC timestamp of review decision. |
RejectionReason |
string? |
Max 500 chars. Shown to the user when rejected. |
AdminBypassNote |
string? |
Max 500 chars. Required when Status = AdminBypassed. |
Indexes: UserId, Status
EF Migration¶
Migration name: AddKycVerification
Adds PhoneNumber and KycStatus to Users. Creates the UserKycDocuments table with all FK constraints and indexes.
API Endpoints¶
Controller: KycController
File: server/src/Batanai.Api/Controllers/KycController.cs
Base route: /api/kyc
All routes require [Authorize].
| Method | Route | Who | Description |
|---|---|---|---|
POST |
/submit |
Any authenticated user | Submit or resubmit KYC details |
GET |
/status |
Any authenticated user | Get own KYC status and metadata |
GET |
/pending |
Admin / SuperAdmin | List all PendingReview submissions (oldest first) |
POST |
/{id}/review |
Admin / SuperAdmin | Approve or reject a specific submission |
POST |
/bypass/{userId} |
Admin / SuperAdmin | Admin-bypass KYC for a user without a document review |
Business Logic¶
Service: KycService
File: server/src/Batanai.Api/Services/KycService.cs
SubmitKycAsync(userId, request)¶
- Validates
FullNameOnIdis present. - For non-
NoDocumentpaths: requires bothIdNumberandDocumentFileId. - Verifies any provided file IDs belong to the submitting user (prevents file-ID spoofing).
- Creates a new
UserKycDocumentrecord withStatus = PendingReview. - Sets
User.KycStatus = PendingReview. - Pushes a
KycSubmittednotification to allAdmin/SuperAdminusers with deep link/admin/kyc.
GetKycStatusAsync(userId)¶
Returns KycStatusDto — the user's current status, rejection reason (if any), submission/review timestamps, and a canResubmit flag (true when status is Rejected).
GetPendingKycAsync()¶
Returns all UserKycDocument records with Status = PendingReview, ordered by SubmittedAt ascending (FIFO review queue).
ReviewKycAsync(kycId, adminId, approve, reason)¶
- Only acts on submissions in
PendingReviewstate. - Rejection requires a non-empty
reason(shown to the user). - Sets
doc.StatusandUser.KycStatustoVerifiedorRejected. - Records
ReviewedByUserIdandReviewedAt. - Pushes
KycApprovedorKycRejectednotification to the user with deep link/profile. - On approval, cancels any pending KYC reminder scheduled notifications for the user.
AdminBypassKycAsync(targetUserId, adminId, note)¶
- Requires a non-empty bypass note (stored as
AdminBypassNote). - Creates a
UserKycDocumentwithIdType = NoDocument,Status = AdminBypassed,ReviewedByUserId = adminId. - Sets
User.KycStatus = AdminBypassed. - Pushes
KycBypassednotification to the user with deep link/profile. - Cancels any pending KYC reminder notifications for the user.
Enforcement Gates¶
KYC is enforced in three places inside ExpenseCycleService and MukandoService. All checks are for Mukando cycles only.
1. Add member to a Mukando cycle (AddMemberAsync)¶
Before adding a user, their KycStatus must be Verified or AdminBypassed. Returns an error naming the user if they fail.
2. Batch-add members to a Mukando cycle (AddMembersBatchAsync)¶
Same check, applied to every user in the batch. All failing user names are collected and returned in a single error message.
3. Start a Mukando cycle (StartAsync)¶
Before advancing a cycle from Draft to Active, all current members are checked. If any are not KYC-cleared, returns a blocking error listing every non-compliant member name.
4. Record Mukando payout (RecordPayoutAsync)¶
Before recording that a round recipient has been paid, their KycStatus is verified. Prevents payouts to users who somehow lost their verified status after the cycle started.
Background KYC Reminders¶
Service: BgTimerHostedService
File: server/src/Batanai.Api/Services/BgTimerHostedService.cs
Method: SendKycRemindersAsync
Interval: Runs every 5 minutes; sends to each user at most once per 24 hours.
Algorithm:
- Find all Mukando cycles with
Status = Draft. - Get the distinct set of member user IDs across those cycles.
- Keep only users whose
KycStatusis notVerified/AdminBypassed. - From those, exclude any user who already received a
KycRemindernotification in the last 24 hours (single batch query). - Send a
KycReminderpush notification to each remaining user with deep link/profile.
Push body:
"You are part of a Mukando cycle that requires identity verification. Complete your KYC in your profile to avoid being blocked when the cycle starts."
Each user's send is wrapped in a try/catch so a failed delivery does not block subsequent users. A summary log line is emitted at Information level showing total sent.
Push Notification Types¶
| Type | ID | Trigger | Recipient |
|---|---|---|---|
KycSubmitted |
42 | User submits KYC | All Admins / SuperAdmins |
KycApproved |
43 | Admin approves submission | Submitting user |
KycRejected |
44 | Admin rejects submission | Submitting user |
KycBypassed |
45 | Admin bypasses KYC for user | Affected user |
KycReminder |
46 | Background service (draft Mukando member) | Unverified member |
File Privacy¶
File: server/src/Batanai.Api/Controllers/FilesController.cs
The file download endpoint checks whether the requested file is linked to any UserKycDocument (as DocumentFileId or SelfieWithIdFileId). If it is:
- The request must be authenticated.
- The caller must be either the file's original uploader or an
Admin/SuperAdmin. - Anonymous or unauthorized callers receive
403 Forbidden.
Regular (non-KYC) files are unaffected.
Angular Client¶
Model — user.model.ts¶
export enum KycStatus {
NotStarted = 0,
PendingReview = 1,
Verified = 2,
Rejected = 3,
AdminBypassed = 4
}
// Added to User interface:
phoneNumber?: string;
kycStatus?: KycStatus;
Service — kyc.service.ts¶
File: client/src/app/core/services/kyc.service.ts
| Method | Description |
|---|---|
getStatus() |
GET /api/kyc/status → KycStatusDto |
submit(request) |
POST /api/kyc/submit → KycDocumentDto |
getPending() |
GET /api/kyc/pending → KycDocumentDto[] |
review(id, approve, reason?) |
POST /api/kyc/{id}/review |
bypass(userId, note) |
POST /api/kyc/bypass/{userId} |
uploadFile(file, folder?) |
POST /api/files/upload?folder=kyc → file ID (number) |
uploadFile extracts the numeric file ID from the returned URL using the pattern /api/files/{id}.
Profile component (profile.component.ts/html/css)¶
Displays a KYC status card at the top of the profile page:
- NotStarted / Rejected — shows the KYC submission form (ID type, ID number, document upload, selfie upload). For
NoDocument, the document/selfie fields are hidden and a plain-language instruction box is shown. - PendingReview — shows a waiting state with submission timestamp.
- Verified / AdminBypassed — shows a green confirmation card; form is hidden.
On resubmit (after rejection), the full form is shown again and a new UserKycDocument is created on the backend.
Cycle detail component (cycle-detail.component.ts/html/css)¶
- The Start Cycle button is disabled when
isMukando && !allMukandoMembersKycReady(). - The draft checklist shows a KYC readiness line:
{n} / {total} members KYC verified. - A
kyc-readiness-panellists every member who is not yetVerified/AdminBypassed, with a colour-coded status chip beside their name.
Sequence: Normal User Flow¶
User API Admin
| | |
|-- POST /api/files/upload -->| |
|<-- { url: "/api/files/42" } | |
| | |
|-- POST /api/kyc/submit ---->| |
| { idType, idNumber, | |
| fullNameOnId, |--- KycSubmitted push ------>|
| documentFileId: 42 } | |
|<-- 200 KycDocumentDto ------| |
| | |
| |<-- GET /api/kyc/pending ----|
| |--- 200 [KycDocumentDto] --->|
| | |
| |<-- POST /api/kyc/7/review --|
| | { approve: true } |
|<-- KycApproved push --------| |
| deep link: /profile | |