KYC — identity verification for users/customers

The whole KYC module reduces to one idea: one kyc document per user holds three uploaded images (ID front, ID back, selfie) plus a status, 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 grep for kyc across src/modules/auth returns nothing; no resolver or guard checks KYCStatus before allowing an action. (Confirmed: src/modules/auth/* has zero KYC references.)
  • No notifications are sent. The approve/reject email + WhatsApp calls in kyc.service.ts are 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.ts but 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; status is 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) via registerEnumType. The admin's own model.ts KYCStatus enum (below) omits DRAFT entirely and uses uppercase string values — a mismatch noted in §9.

Relationships

  • userIdUser (resolved server-side to the user profile, see §3).
  • selfieId / idFrontId / idBackIdFileUpload (each resolved to a FileUpload with a uri).
  • Embedded on Customer as a non-resolved static field kyc: KYC (customer.dto.ts) — i.e. the customer query can carry a nested KYC sub-selection, but there is no @ResolveField for kyc on 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 note argument on approve/reject is accepted but ignored — the service methods take only id and never read note (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 record

So 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 record

State 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? status is a passthrough field on UpdateKYCInput/create() — the client must set status: PENDING when the user finishes uploading. There is no server-side auto-transition from DRAFT → PENDING. The admin list/summary keys off PENDING for the "awaiting review" bucket.

Side effects

  • Audit trail: updateKycDoc snapshots UPDATE; approveKycDoc/rejectKycDoc snapshot STATUS_CHANGE (@AuditMeta({ module: 'kyc', collection: 'kycs', ... })). See audit-trail.
  • File cleanup on failure: in updateKycDoc, if KYCSvc.create throws after files were uploaded, the resolver .catch deletes the just-uploaded idBack/idFront/selfie uploads to avoid orphans.
  • Notifications: none active (commented out).
  • Transactionality: none — create/update/approve/reject are 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)

  1. Client calls updateKycDoc(kyc: UpdateKYCInput) with multipart idFront/idBack/selfie (and optionally status, userId).
  2. KYCResolver.create uploads each present file via fileUploadSvc.upload({ type:"stream", module:"kyc" }), then stamps selfieId/idFrontId/idBackId from the returned upload _ids.
  3. Calls KYCSvc.create({ ...kyc, userId: kyc.userId || "", createdBy: currentUser._id }).
  4. KYCSvc.create runs the upsert rule (findLast by userId): create new, update the existing DRAFT, or throw if APPROVED/PENDING.
  5. Returns the KYC; resolve-fields hydrate user, 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 create throws → uploaded files are deleted in the resolver .catch.

6.2 Admin reviews and decides (admin screen → op → service → DB)

  1. Admin opens KYC Verification page (/kycKycPage).
  2. fetchKycPageKYCPage query (admin name) → server kycDocPageKYCRepository.page aggregation → { data, totalRecords, summary }.
  3. Admin picks a row's Update Status dropdown → ApConfirmModal → on OK calls updateStatus(id, value).
  4. updateStatus maps APPROVED → approve mutation, anything else → reject mutation (so selecting PENDING in the UI actually fires reject — see §9).
  5. Server approveKycDoc/rejectKycDocKYCSvc.approve|rejectKYCRepo.update(id, { status }) → audit STATUS_CHANGE.
  6. Admin context optimistically patches the local row's status and 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 by ApGuardBuilder(session).isAuth() (auth-only; no permission check at the page level) → renders <KycPage /> inside MainLayout. (Note: the page is not wrapped in KycContextProvider here — 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 → sets filter.user), ApDateRangePicker (wired to a no-op handleDateChange — date filtering is not implemented), ApDropDown status filter (ALL/PENDING/APPROVED/REJECTED — applied client-side via kyc.filter(...)), Clear + Search buttons.
  • ApTable columns: Customer Name (links to /customer/{user._id}/detail), ID Front / ID Back / Selfie (each a "View …" link opening record.<img>.uri in a new tab), Status, Date Submitted (fmtDate(createdAt)), Update Status (ApDropDown with PENDING/APPROVED/REJECTED → confirm modal → updateStatus).
  • Pagination is offset-based; pageSize default 50, page/pageSize translated to skip/take in 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 / findById to resolve the user field and to look up the user during approve/reject.
  • FileUploadModule (forwardRef) — stores/serves the three document images; see files-assets-upload.
  • ApConfigModuleappConfigSvc.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 Customercustomer.dto.ts embeds kyc: KYC, and CustomerModule imports KYCModule (forwardRef). KYC is the identity record behind a customer.
  • No cron/jobs, no external IDV/OCR service.

9. Gotchas & project-specific rules

  1. Server op names ≠ admin op names (likely broken against current schema). schema.gql exposes updateKycDoc / approveKycDoc / rejectKycDoc / findKycDoc / kycDocPage, but the admin (gql/query.ts) calls approveKYC, 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 *KYC names. Treat this as a rename in flight; align both sides on the *KycDoc names (or whatever the running server actually exposes) when rebuilding.
  2. Selecting "PENDING" in the Update-Status dropdown calls reject. updateStatus only special-cases APPROVED; every other value (including PENDING) routes to the reject mutation. There is no way to push a record back to PENDING from the admin.
  3. No KYC gating anywhere. Approving/rejecting changes only the status field. No login, customer creation, sale, or any other flow checks KYCStatus. If KYC is meant to gate behavior, that logic is not implemented — add it explicitly in the consumer (e.g. customer/sales/auth).
  4. userId can be an empty string. The resolver stamps userId: kyc?.userId || "". An empty userId then makes approve/reject throw on userSvc.findById(""). Always pass a real userId on submit.
  5. Enum value drift. Stored values are lowercase ("draft"…), the GraphQL enum exposes uppercase keys, and the admin model.ts KYCStatus enum omits DRAFT and uses uppercase string values. The status filter/compares in the admin (record.status === filter.status) assume the GraphQL-returned uppercase form.
  6. No tenant scoping in the list query. KYCRepository.page builds a $match from only userId / status (+ optional $lookupUser for free-text user). It does not filter by companyId/branchId, so the admin KYC list is effectively global across tenants. See multi-tenancy.
  7. Summary counts ignore the active filter. KYCPageResolver.summary calls count with no userId/user/date filter, so the four summary cards always reflect global totals, not the filtered view.
  8. note is accepted but discarded on approve/reject (service ignores it; no rejection-reason is persisted).
  9. Date range filter is a no-op in the admin (handleDateChange is empty; kycDocPage/KYCPageInput has no date args anyway).
  10. 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