User Profiles & Accounts
What it is
The user module owns account-level concerns: profile data, authentication settings, identity verification (BVN/NIN/biometrics), contact sync, and the user's personal wallet. It's one of the largest modules in the codebase (60+ endpoints). Contact-sync isn't covered here yet.
Core concepts
- Profile vs. settings vs. security are three distinct update surfaces with their own endpoints rather than one big "update user" call:
PATCH /user/profile-update(name, bio, avatar, DOB, etc.),PATCH /user/settings(feature toggles like biometric auth),PATCH /user/username(username specifically, since it has uniqueness/availability concerns),PATCH /user/password/update, and separate PIN endpoints for the transaction PIN used on wallet actions. - This module is one of the few already on the Zod-native validation lane — its DTOs (
src/modules/user/dto/user.dto.ts) are the schema that runs at request time and what the OpenAPI spec is generated from directly, no doc-only duplication needed.
How it works
Profile update
PATCH /user/profile-update accepts a large set of optional fields — name, bio (silently truncated to 150 chars rather than rejected), username, phone, avatar, gender, dateOfBirth, firstName/lastName, partyPlanningVibe, socialMedia, referralCode. Only send what changed; the schema is .strict(), so unknown fields are rejected outright rather than silently ignored.
dateOfBirth accepts either DD-MM-YYYY or YYYY-MM-DD and normalizes to YYYY-MM-DD internally — but empty string is explicitly allowed through as a way to clear the field.
partyPlanningVibe accepts either a single enum value or an array of up to 7 — the union exists for backward compatibility with clients still sending a single value.
Settings toggle (step-up auth pattern)
PATCH /user/settings takes a configKey (from SettingsConfigKeysEnum) and a configValue. Most settings toggle freely, but enabling/disabling biometric_authentication requires the account password in the same request — a superRefine on the schema rejects the request if configKey is biometric auth and password is missing. This is a step-up-confirmation pattern: don't add new security-sensitive settings to this endpoint without the same password check, or you'll create a way to silently flip a security setting without re-authentication.
Identity verification (KYC)
Verification is tracked as a set of independent flags (KycStatusFlags), not a single "verified" boolean: IDENTITY_SUBMITTED, IDENTITY_VERIFIED, BVN_VERIFIED, NIN_VERIFIED, KYC_STARTED, KYC_COMPLETE (full biometric — selfie + document). Routes are gated per-flag via isUserKycStatus([...]) middleware, so different actions can demand different levels of trust rather than an all-or-nothing gate.
BVN and NIN both follow the same initiate → verify two-step OTP pattern: POST /user/bvn/initiate sends an OTP (via the identity provider, IdentityPass) to the phone number on record for that BVN/NIN; POST /user/bvn/verify (identificationNumber + 6-digit otp) completes it. Same shape for /user/nin/initiate / /user/nin/verify. KYC_COMPLETE is a separate, heavier flow requiring a selfie + document upload processed via webhook (KYC_STARTED marks that submission was received; completion flips async).
This gating is enforced, not cosmetic — see the wallet section below for a concrete example (bank transfers require BVN_VERIFIED).
Personal wallet (PiggyVest Business-backed)
Distinct from an event's PartyBank wallet (see the PartyBank guide) — this is the user's own wallet, used for things like promoter commission payouts and personal transfers.
POST /user/wallet(requires PIN) — creates the wallet.GET /user/wallet/status— creation status (creation may be async/pending).GET /user/wallet/GET /user/wallet/details— info and live balance.GET /user/wallet/funding-account/GET /user/wallet/:walletRef/funding-account— dedicated account details for funding the wallet via bank transfer.POST /user/wallet/reserve-account— provisions a regular PiggyVest "pocket" account.- Moving money out:
POST /user/wallet/transfer/pocket(to another PiggyVest "pocket tag" — validate first viaPOST /user/wallet/validate-pocket-tag) orPOST /user/wallet/transfer/bank(to an external bank account — requiresBVN_VERIFIEDKYC status, enforced byisUserKycStatus([KycStatusFlags.BVN_VERIFIED])on the route).POST /user/wallet/verify-bank/GET /user/wallet/bankssupport the bank-transfer flow (resolve account name, list supported banks). GET /user/wallet/cards,GET /user/transactions— display-oriented reads.
Account deletion (EU-compliant archival, not a hard delete)
GET /user/account/deletion/terms returns the terms/acknowledgments a client should show before letting a user proceed. POST /user/account/deletion/initiate (transaction PIN + password, both required — the heaviest gate in the module) triggers archiveAndDeleteUser(): the user's data is copied into an archive record with direct identifiers hashed (emailHash/phoneHash via hashString()) rather than the account being hard-deleted outright — framed internally as "EU-compliant archival." A stale in-repo doc describes this as a "dummy implementation that doesn't perform actual deletion"; that's no longer true as of this session's check — the archival path is real and runs on every call.
Onboarding nudges ("pending tasks")
GET /user/pending-tasks returns a small set of dismissible prompt cards (e.g. "Complete your KYC," "Enable 2FA," "Refer & Earn"), and DELETE /user/pending-tasks/:taskId dismisses one. Dismissals are stored in a process-local in-memory Map (UserService.dismissedTasks), not the database — a dismissal will not survive a server restart or be visible from a different server instance behind the load balancer. Don't rely on this being durable; if a user reports a dismissed card reappearing, this is very likely why, not a bug in the dismiss call itself.
GET /user/details also returns onboarding-guidance fields (onboardingCompleted, nextStep, nextMessage, nextMeta, nextScreen) so a client can redirect a user with an incomplete profile straight to the next required screen instead of the app inferring it client-side.
Key business rules & edge cases
-
External bank transfers are KYC-gated by design — a user who hasn't verified their BVN can fund and hold a wallet balance, and transfer between PiggyVest pockets, but cannot move money to an external bank account. This is the concrete enforcement point for "why can't I withdraw" support questions.
-
createUserWalletand other money-movement endpoints requireencryptedPinMiddleware— a transaction PIN separate from the account password, matching the same step-up pattern used by the biometric-auth setting toggle. -
Username changes go through a dedicated endpoint (not the general profile update) with its own format rule (
^[a-zA-Z0-9_]+$, 3–30 chars) — checkGET /user/username/:username/availabilitybefore submitting to avoid a failed write. -
UpdateProfileValidationRuleand the others are.strict()— a client sending an extra unrecognized field gets a 422, not a silently-dropped field. If you're debugging "why did my update fail," check for typos/extra keys before assuming a value-validation issue. -
The email and phone number "change" flows are separate initiate/verify endpoint pairs (
/email/change/initiate+/verify,/phone/change/initiate+/verify) rather than a direct field update — both require an OTP-style confirmation step since they're account-recovery-sensitive.
Related endpoints
See the API Reference — GET /user/details, PATCH /user/profile-update, PATCH /user/settings, PATCH /user/username.
Also: GET /v1/user/account/deletion/terms, POST /v1/user/account/deletion/initiate, GET /v1/user/pending-tasks, DELETE /v1/user/pending-tasks/{taskId} — not yet in the generated OpenAPI spec.
Related code
src/modules/user/user.route.ts,user.controller.tssrc/modules/user/dto/user.dto.ts— the Zod DTOs, now also the OpenAPI sourcesrc/modules/user/user.openapi.ts— path registrations for the profile/settings endpointssrc/shared/enum/kyc.enum.ts—KycStatusFlags;src/shared/middlewares/—isUserKycStatus,encrypted-pin.middlewaresrc/services/userWallet.service.ts— PiggyVest Business wallet integration