KYC — identity verification for users/customers
The whole KYC module reduces to one idea: one
kycdocument per user holds three uploaded images (ID front, ID back, selfie) plus astatus, and an admin moves that status through a verification lifecycle (draft → pending → approved | rejected). It is a thin document-collection + status-machine module. Nothing in zerp currently gates login or usage on KYC status — verification is informational/compliance-only as implemented.
Source: BE src/modules/kyc · Admin src/modules/kyc (page src/pages/kyc.tsx)
1. Purpose & scope
What it does:
- Stores a per-user identity-verification record: ID front, ID back, and selfie images (each an upload FK), plus a
status. - Lets the end user submit/replace their documents (one upsert mutation that handles both first-submit and re-submit of a
draft). - Lets an admin approve or reject a submitted record, and view a paginated list with summary counts.
What it explicitly does NOT do:
- Does not gate login, session, or any feature. A
grepforkycacrosssrc/modules/authreturns nothing; no resolver or guard checksKYCStatusbefore allowing an action. (Confirmed:src/modules/auth/*has zero KYC references.) - No notifications are sent. The approve/reject email + WhatsApp calls in
kyc.service.tsare commented out (see §4 and notifications). - No company/branch scoping is applied in queries. Unlike most zerp collections, the KYC list aggregation does not filter by
companyId/branchId(see §9). - No OCR / external IDV provider. Documents are stored as uploaded files only; there is no automated extraction or third-party verification call. (A commented-out auto-extract-and-auto-approve flow exists in
user.service.tsbut is dead code.)
What entity it verifies: a User (KYC.userId → User). KYC is surfaced on the Customer object via a static kyc: KYC field (customer.dto.ts), so in practice it is the identity check for customer-kind users.
2. Data model
kyc collection — one verification record per user
kyc.schema.ts. Extends BaseSchema (so it inherits ref, companyId, branchId, createdBy, updatedBy, timestamps, soft-delete fields). Soft-delete is enabled via mongoose-delete (deletedAt: true).
| field | type | required? | description |
|---|---|---|---|
_id |
ObjectId | auto | document id |
userId |
ObjectId | no | FK → User. The person being verified. Set via setter BaseSchema.toObjectId. Stamped from the resolver as `kyc.userId |
status |
KYCStatus |
yes | verification state. Default DRAFT. |
selfieId |
ObjectId | no | FK → FileUpload (selfie image). |
idFrontId |
ObjectId | no | FK → FileUpload (front of ID). |
idBackId |
ObjectId | no | FK → FileUpload (back of ID). |
createdBy |
ObjectId | (BaseSchema) | the actor who created the record (stamped from GqlCurrentUser in the resolver). |
createdAt / updatedAt |
number (unix) | (BaseSchema) | timestamps. |
There is no per-record "company/branch" intent beyond what BaseSchema provides, and the list query ignores them. The three image FKs are the document collection;
statusis the only mutable state an admin changes.
Enum — KYCStatus (kyc.schema.ts)
export enum KYCStatus {
DRAFT = "draft", // record exists, not yet submitted for review (default)
APPROVED = "approved", // admin accepted the documents
REJECTED = "rejected", // admin declined the documents
PENDING = "pending", // submitted, awaiting admin review
}Stored values are lowercase (
"draft","pending", …). The GraphQL enum surfaces them as the uppercase enum keys (DRAFT,APPROVED,REJECTED,PENDING) viaregisterEnumType. The admin's ownmodel.tsKYCStatusenum (below) omitsDRAFTentirely and uses uppercase string values — a mismatch noted in §9.
Relationships
userId→User(resolved server-side to the user profile, see §3).selfieId/idFrontId/idBackId→FileUpload(each resolved to aFileUploadwith auri).- Embedded on
Customeras a non-resolved static fieldkyc: KYC(customer.dto.ts) — i.e. the customer query can carry a nested KYC sub-selection, but there is no@ResolveFieldforkycon the Customer resolver (grep ResolveField.*kyc→ none). It is populated only when a query/aggregation explicitly joins it.
3. API surface
GraphQL, code-first. Resolvers in kyc.resolver.ts. Important: the resolver's @Mutation/@Query name: options and the generated schema.gql use *KycDoc / findKycDoc / kycDocPage names, but the admin client uses different op names (approveKYC, rejectKYC, KYCPage, findKYCById) — see §9. The table below lists the authoritative server names from schema.gql.
| Operation | Type | Input | Returns | Permission |
|---|---|---|---|---|
findKycDoc |
Query | QueryKYCInput! (userId?, status?) |
[KYC!]! |
@ApGqlAuthorize() (auth only) |
kycDocPage |
Query | KYCPageInput! (skip!, take!, userId?, user?, status?) |
KYCPageResult! |
@ApGqlAuthorize() (auth only) |
updateKycDoc |
Mutation | UpdateKYCInput! (userId?, status?, idFront?, idBack?, selfie? — all Upload) |
KYC! |
@ApGqlAuthorize() + @AuditMeta(UPDATE) |
approveKycDoc |
Mutation | id: String!, note: String |
Boolean! |
@ApGqlAuthorize({ permission: { subject: "kyc", action: "approve" } }) + @AuditMeta(STATUS_CHANGE) |
rejectKycDoc |
Mutation | id: String!, note: String |
Boolean! |
@ApGqlAuthorize({ permission: { subject: "kyc", action: "reject" } }) + @AuditMeta(STATUS_CHANGE) |
The
noteargument on approve/reject is accepted but ignored — the service methods take onlyidand never readnote(return this.KYCSvc.approve(id)).
Resolve-fields on KYC (KYCResolver)
| field | resolver | source |
|---|---|---|
user |
userSvc.getProfile(args.userId) |
User module |
idFront |
fileUploadSvc.findById(args.idFrontId) |
FileUpload |
idBack |
fileUploadSvc.findById(args.idBackId) |
FileUpload |
selfie |
fileUploadSvc.findById(args.selfieId) |
FileUpload |
Resolve-field on KYCPageResult (KYCPageResolver)
summary → runs four KYCSvc.count(...) calls (total, pending, approved, rejected) and returns KYCSummary { totalCount, totalPending, totalApproved, totalRejected }. Note: the summary's totals are global counts ignoring the page filter (no userId/status passed to count).
Input/output shapes (kyc.dto.ts)
@InputType() class CommonKYCInput {
userId?: string;
status?: KYCStatus;
idFront: Upload; // GraphQLUpload (multipart file)
idBack: Upload;
selfie: Upload;
}
@InputType() class UpdateKYCInput extends PartialType(CommonKYCInput) {}
@InputType() class QueryKYCInput extends PartialType(PickType(CommonKYCInput, ["status","userId"])) {}
@InputType() class KYCPageInput {
skip: number; take: number; // offset pagination (NOT page/pageSize)
userId?: string; // exact-match filter on userId
user?: string; // free-text user lookup ($lookupUser join + search)
status?: KYCStatus;
}
@ObjectType() class KYCSummary { totalCount; totalPending; totalApproved; totalRejected }
@ObjectType() class KYCPageResult { summary?: KYCSummary; totalRecords; data: [KYC] }No REST controllers — module is GraphQL-only.
4. Business rules & state machine
All rules live in kyc.service.ts.
Upsert rule on create() (drives updateKycDoc)
create(model):
exist = findLast({ userId }) // most recent record for this user
if exist.status === APPROVED → throw "Your ID verification is already approved"
if exist.status === PENDING → throw "user already started ID verification process..."
if exist.status === DRAFT → update(exist._id, model) // re-edit the draft in place
else → KYCRepo.create(model) // first-ever recordSo a user can only have one active verification attempt at a time. A new submission is blocked while the previous one is APPROVED or PENDING; a DRAFT is editable; a REJECTED record allows a brand-new record to be created.
Approve / Reject
approve(id):
config = appConfigSvc.findLast() // fetched but only used by the (disabled) messaging
kyc = KYCRepo.findById(id)
user = userSvc.findById(kyc.userId) // ⚠ throws if userId is empty/missing
KYCRepo.update(id, { status: APPROVED })
// email + WhatsApp notifications are COMMENTED OUT
return true
reject(id):
... KYCRepo.update(id, { status: REJECTED }); return true
// an "already approved" guard exists but is commented out → you CAN reject an approved recordState machine
updateKycDoc (user submits / re-submits)
│
┌─────────────┴──────────────┐
▼ ▼
(no record yet) status == DRAFT
create → DRAFT* update draft in place
│
│ (submit moves it to PENDING — see note)
▼
PENDING ──── approveKycDoc ──▶ APPROVED (re-submit blocked: "already approved")
│
└──────── rejectKycDoc ──▶ REJECTED (user may submit a fresh record)
APPROVED ── rejectKycDoc ──▶ REJECTED (no guard; the "already approved" check is commented out)
* default status on a newly created row is DRAFT (schema default).
How does a record reach
PENDING?statusis a passthrough field onUpdateKYCInput/create()— the client must setstatus: PENDINGwhen the user finishes uploading. There is no server-side auto-transition fromDRAFT → PENDING. The admin list/summary keys offPENDINGfor the "awaiting review" bucket.
Side effects
- Audit trail:
updateKycDocsnapshotsUPDATE;approveKycDoc/rejectKycDocsnapshotSTATUS_CHANGE(@AuditMeta({ module: 'kyc', collection: 'kycs', ... })). See audit-trail. - File cleanup on failure: in
updateKycDoc, ifKYCSvc.createthrows after files were uploaded, the resolver.catchdeletes the just-uploadedidBack/idFront/selfieuploads to avoid orphans. - Notifications: none active (commented out).
- Transactionality: none —
create/update/approve/rejectare plain sequential repo calls, not wrapped in a Mongo session.
5. Permissions
Permission subject/module key: kyc (registered in company.interface.ts permission-module list). See permissions-access.
| Operation | Guard |
|---|---|
findKycDoc, kycDocPage, updateKycDoc |
@ApGqlAuthorize() — authenticated only, no specific permission/action required. |
approveKycDoc |
@ApGqlAuthorize({ permission: { subject: "kyc", action: "approve" } }) |
rejectKycDoc |
@ApGqlAuthorize({ permission: { subject: "kyc", action: "reject" } }) |
So reading and updating KYC documents only needs a valid session; only approve/reject are gated by explicit kyc:approve / kyc:reject permissions (CASL-style subject+action). Login/auth itself is documented in auth.
6. Flows
6.1 User submits / re-submits documents (happy path)
- Client calls
updateKycDoc(kyc: UpdateKYCInput)with multipartidFront/idBack/selfie(and optionallystatus,userId). KYCResolver.createuploads each present file viafileUploadSvc.upload({ type:"stream", module:"kyc" }), then stampsselfieId/idFrontId/idBackIdfrom the returned upload_ids.- Calls
KYCSvc.create({ ...kyc, userId: kyc.userId || "", createdBy: currentUser._id }). KYCSvc.createruns the upsert rule (findLastbyuserId): create new, update the existingDRAFT, or throw ifAPPROVED/PENDING.- Returns the
KYC; resolve-fields hydrateuser,idFront,idBack,selfie.
Unhappy paths:
- Already
APPROVED→ throws "Your ID verification is already approved". - Already
PENDING→ throws "user already started ID verification process...". - Upload succeeds but
createthrows → uploaded files are deleted in the resolver.catch.
6.2 Admin reviews and decides (admin screen → op → service → DB)
- Admin opens KYC Verification page (
/kyc→KycPage). fetchKycPage→KYCPagequery (admin name) → serverkycDocPage→KYCRepository.pageaggregation →{ data, totalRecords, summary }.- Admin picks a row's Update Status dropdown →
ApConfirmModal→ on OK callsupdateStatus(id, value). updateStatusmapsAPPROVED → approvemutation, anything else →rejectmutation (so selecting PENDING in the UI actually fires reject — see §9).- Server
approveKycDoc/rejectKycDoc→KYCSvc.approve|reject→KYCRepo.update(id, { status })→ auditSTATUS_CHANGE. - Admin context optimistically patches the local row's
statusand toasts "KYC Record Updated".
Unhappy path: if kyc.userId is empty, userSvc.findById(kyc.userId.toString()) inside approve/reject will throw (no try/catch) → mutation errors out.
7. Admin UI
- Route:
src/pages/kyc.tsx→ guarded byApGuardBuilder(session).isAuth()(auth-only; no permission check at the page level) → renders<KycPage />insideMainLayout. (Note: the page is not wrapped inKycContextProviderhere — the provider is mounted higher in the app tree.) - Screen:
src/modules/kyc/page.tsx(KycPage).
Layout:
ApPageTitle "KYC Verification".- Four
ApSummaryCards: Total KYC (totalRecords), Approved (Summary.totalApproved), Pending (Summary.totalPending), Rejected (Summary.totalRejected). - Filter bar:
ApSearchInput(by user name, 500ms debounce → setsfilter.user),ApDateRangePicker(wired to a no-ophandleDateChange— date filtering is not implemented),ApDropDownstatus filter (ALL/PENDING/APPROVED/REJECTED — applied client-side viakyc.filter(...)), Clear + Search buttons. ApTablecolumns: Customer Name (links to/customer/{user._id}/detail), ID Front / ID Back / Selfie (each a "View …" link openingrecord.<img>.uriin a new tab), Status, Date Submitted (fmtDate(createdAt)), Update Status (ApDropDownwith PENDING/APPROVED/REJECTED → confirm modal →updateStatus).- Pagination is offset-based;
pageSizedefault 50,page/pageSizetranslated toskip/takein the context.
Context (context.tsx, usekycstate()):
| method / value | role |
|---|---|
fetchKycPage(page) |
maps {page,pageSize} → {skip,take,user}, calls KYCPage query, sets kyc, totalRecords, Summary. |
updateStatus(id, status, note?) |
picks approve vs reject mutation by status === APPROVED, optimistically updates the local row, toasts. |
kyc, setkyc, loading, updateLoading, totalRecords, Summary, limit |
state. |
GraphQL ops live in gql/query.ts + fragments in gql/fragment.ts (KYC fragment selects idFront/idBack/selfie { _id uri name type } and nested user { _id id kind referralId idNumber username name email }). There is no Formik form — the admin only changes status; document upload is done by the end-user app, not this admin screen.
8. Dependencies & integrations
UserModule(forwardRef) —userSvc.getProfile/findByIdto resolve theuserfield and to look up the user during approve/reject.FileUploadModule(forwardRef) — stores/serves the three document images; see files-assets-upload.ApConfigModule—appConfigSvc.findLast()is fetched in approve/reject (intended to supply approve/reject message templates), but only used by the now-disabled messaging.MessageModule(forwardRef) — imported for email/WhatsApp on decision, currently commented out (notifications).AuthModule(forwardRef) +PermissionModule(forwardRef) — for@ApGqlAuthorize/kyc:approve|reject. See auth and permissions-access.- Consumed by Customer —
customer.dto.tsembedskyc: KYC, andCustomerModuleimportsKYCModule(forwardRef). KYC is the identity record behind a customer. - No cron/jobs, no external IDV/OCR service.
9. Gotchas & project-specific rules
- Server op names ≠ admin op names (likely broken against current schema).
schema.gqlexposesupdateKycDoc/approveKycDoc/rejectKycDoc/findKycDoc/kycDocPage, but the admin (gql/query.ts) callsapproveKYC,rejectKYC,KYCPage,findKYCById. As checked in, the admin's queries/mutations do not match the generated schema — they would fail unless the deployed backend still exposes the older*KYCnames. Treat this as a rename in flight; align both sides on the*KycDocnames (or whatever the running server actually exposes) when rebuilding. - Selecting "PENDING" in the Update-Status dropdown calls
reject.updateStatusonly special-casesAPPROVED; every other value (includingPENDING) routes to therejectmutation. There is no way to push a record back toPENDINGfrom the admin. - No KYC gating anywhere. Approving/rejecting changes only the
statusfield. No login, customer creation, sale, or any other flow checksKYCStatus. If KYC is meant to gate behavior, that logic is not implemented — add it explicitly in the consumer (e.g. customer/sales/auth). userIdcan be an empty string. The resolver stampsuserId: kyc?.userId || "". An emptyuserIdthen makesapprove/rejectthrow onuserSvc.findById(""). Always pass a realuserIdon submit.- Enum value drift. Stored values are lowercase (
"draft"…), the GraphQL enum exposes uppercase keys, and the adminmodel.tsKYCStatusenum omitsDRAFTand uses uppercase string values. Thestatusfilter/compares in the admin (record.status === filter.status) assume the GraphQL-returned uppercase form. - No tenant scoping in the list query.
KYCRepository.pagebuilds a$matchfrom onlyuserId/status(+ optional$lookupUserfor free-textuser). It does not filter bycompanyId/branchId, so the admin KYC list is effectively global across tenants. See multi-tenancy. - Summary counts ignore the active filter.
KYCPageResolver.summarycallscountwith nouserId/user/date filter, so the four summary cards always reflect global totals, not the filtered view. noteis accepted but discarded on approve/reject (service ignores it; no rejection-reason is persisted).- Date range filter is a no-op in the admin (
handleDateChangeis empty;kycDocPage/KYCPageInputhas no date args anyway). - No transaction wraps document upload + record write; on a mid-flow failure the resolver compensates by deleting the orphaned uploads, but the write itself isn't atomic.
Related: customer · auth · permissions-access · files-assets-upload · audit-trail · notifications · multi-tenancy