Verificación de edad e identidad — Referencia técnica
← Volver a Verificación de edad e identidad
Cubre dos mecanismos de cumplimiento distintos, ambos deliberadamente separados de la insignia de verificación de notoriedad (esa insignia trata sobre autenticidad/notoriedad y no tiene nada que ver con edad ni KYC):
- Edad — se aplica una sola vez, al registrarse, a partir de la fecha de nacimiento de la cuenta.
- Identidad (KYC) — un flujo manual, revisado por admin, de documento oficial + selfie, que condiciona los retiros de dinero real.
Dónde vive esto
Backend — edad
apps/backend/validators/user.validator.js—validateDateOfBirthexige una edad mínima de 18 (subida desde 13 en esta pasada), yvalidateRegistrationInputahora requieredateOfBirth(antes opcional). Esto era un hueco real: la columnadate_of_birthyvalidateDateOfBirthya existían, pero el registro trataba el campo como opcional y el formulario de registro nunca lo pedía, así que en la práctica ninguna verificación de edad corría de punta a punta.apps/backend/database/models/user.js— el atributo preexistentedateOfBirth(date_of_birth,DATEONLY); sin cambios.
Backend — identidad
apps/backend/managers/user-managers/identity-verification.manager.js—submitRequest,getStatus,getPendingRequests,approve,reject, yrequireApprovedForCashout(el guard que llama el manager de retiros).apps/backend/validators/identity-verification.validator.js—validateSubmitInput(ambas URLs requeridas),validateRejectionReason(requerido, no vacío).apps/backend/graphql/types/identity-verification.type.js+resolvers/identity-verification.resolver.js— la superficie de GraphQL (queries/mutations abajo). Las mutations de admin se protegen concontext.admin+adminUserManager.hasPermission(admin.adminId, 'MODERATE_CONTENT'), el mismo patrón que las disputas de productos y los reembolsos de compras de posts.apps/backend/database/models/user.js— columnas nuevasidentityVerificationStatus(enumnone|pending|approved|rejected),identityDocumentUrl,identitySelfieUrl,identityVerificationRequestedAt,identityVerifiedAt,identityVerifiedByAdminId,identityRejectionReason. Deliberadamente no reutiliza las columnasisVerified/verificationStatus/verificationCategoryde la misma tabla — esas respaldan la insignia de notoriedad, una afirmación distinta revisada con criterios distintos.apps/backend/database/migrations/20260717070000-add-identity-verification-columns.js— idempotente (guards condescribeTable), siguiendo el patrón establecido de migraciones.apps/backend/managers/coin-managers/coin-cashout.manager.js— tantorequestCashout(Stripe) comorequestManualCashout(CLABE/RFC de México) ahora llaman aidentityVerificationManager.requireApprovedForCashout(userId, context)al inicio, así que un usuario con identidad no aprobada no puede solicitar un retiro.apps/backend/validators/notification.validator.js—identity_verification_approved/identity_verification_rejectedagregados a la lista blanca de tipos de notificación.
Frontend (frontend-nextjs)
apps/frontend-nextjs/src/components/Login.tsx— el formulario de registro ahora tiene un campo obligatorio de fecha de nacimiento con un control 18+ del lado del cliente (el backend también lo exige) y pasadateOfBirthen el input de la mutationRegister.apps/frontend-nextjs/src/page-components/settings/IdentityVerificationPage.tsx(ruta/settings/identity-verification) — sube la foto del documento + la selfie al endpoint REST/uploadexistente (no hay mutation de GraphQL para subir medios), llama asubmitIdentityVerification, y muestra el estado propio de quien llama (none/pending/approved/rejected + motivo de rechazo, con reenvío tras un rechazo) desdemyIdentityVerificationStatus. Enlazada desde el menú de configuración enSettingsPage.tsx.apps/frontend-nextjs/src/page-components/PaymentsPage.tsx— la pestaña de Retiros muestra un aviso de bloqueo por verificación de identidad (con enlace a la página de configuración, o un estado "en revisión") cuando la identidad de quien llama no está aprobada, para que el bloqueo del backend no sea una sorpresa.apps/frontend-nextjs/src/page-components/settings/PayoutsPage.tsx(ruta/settings/payouts, enlazada desde el menú de configuración) — una página de configuración de retiros más reciente y dedicada, que muestra el mismo aviso de bloqueo por verificación de identidad.
Frontend (frontend-admin)
apps/frontend-admin/src/app/moderation/identity-verifications/page.tsx— la cola de revisión: listaadminPendingIdentityVerifications, abre un modal que muestra el documento + la selfie enviados, y aprueba (adminApproveIdentityVerification) o rechaza con un motivo obligatorio (adminRejectIdentityVerification). Protegida conMODERATE_CONTENT(super_admin la omite), reflejando la verificación del lado del servidor. Enlazada desdeAdminLayout.tsx.
Checklist de implementación técnica
- Edad mínima de 18 aplicada +
dateOfBirthobligatorio al registrarse —user.validator.js, y el formulario de registro enLogin.tsxahora la recoge -
submitIdentityVerification—identity-verification.resolver.js+IdentityVerificationPage.tsx; documento + selfie subidos vía/upload, reenviable tras un rechazo -
myIdentityVerificationStatus— pantalla de estado enIdentityVerificationPage.tsxy el aviso de bloqueo de la pestaña de retiros enPaymentsPage.tsx -
adminPendingIdentityVerifications/adminApproveIdentityVerification/adminRejectIdentityVerification— cola de admin enfrontend-admin, protegida conMODERATE_CONTENT - Bloqueo de retiros —
coin-cashout.manager.jsrequestCashout/requestManualCashoutrequieren identidad aprobada víarequireApprovedForCashout - Verificación de identidad automatizada / de terceros (Stripe Identity, Persona, etc.) — no construida; hoy es totalmente manual/revisada por admin
- Reverificación de edad más allá del control de fecha de nacimiento al registrarse — no construida
API de GraphQL
Definido en graphql/types/identity-verification.type.js, resuelto en graphql/resolvers/identity-verification.resolver.js.
El documento y la selfie deben subirse primero al endpoint REST /upload (igual que cualquier otra subida de medios en este código); las URLs resultantes se pasan a submitIdentityVerification. Enviar pone el estado de quien llama en pending; luego un admin aprueba o rechaza. Un usuario rechazado puede reenviar, lo que lo regresa a pending y borra el motivo de rechazo anterior.
# El estado propio de quien llama
query MyIdentityVerificationStatus {
myIdentityVerificationStatus {
identityVerificationStatus # none | pending | approved | rejected
identityVerificationRequestedAt
identityVerifiedAt
identityRejectionReason
}
}
# Enviar / reenviar (documentUrl + selfieUrl son URLs de S3 de /upload)
mutation SubmitIdentityVerification($input: IdentityVerificationSubmitInput!) {
submitIdentityVerification(input: $input) {
identityVerificationStatus
identityVerificationRequestedAt
}
}
# Cola de revisión de admin (requiere MODERATE_CONTENT)
query PendingIdentityVerifications($limit: Int, $offset: Int) {
adminPendingIdentityVerifications(limit: $limit, offset: $offset) {
id username email firstName lastName profilePicture
identityVerificationStatus
identityDocumentUrl
identitySelfieUrl
identityVerificationRequestedAt
}
}
# Decisiones de admin (requieren MODERATE_CONTENT)
mutation ApproveIdentityVerification($userId: ID!) {
adminApproveIdentityVerification(userId: $userId) { identityVerificationStatus }
}
mutation RejectIdentityVerification($userId: ID!, $reason: String!) {
adminRejectIdentityVerification(userId: $userId, reason: $reason) { identityVerificationStatus }
}
Modelo de datos (columnas en users)
| Columna | Descripción |
|---|---|
identity_verification_status | none | pending | approved | rejected |
identity_document_url | URL de S3 de la foto del documento de identidad enviada |
identity_selfie_url | URL de S3 de la selfie enviada |
identity_verification_requested_at | Cuándo se envió la solicitud (más reciente) |
identity_verified_at | Cuándo la aprobó un admin |
identity_verified_by_admin_id | El admin que aprobó/rechazó (FK → admin_user) |
identity_rejection_reason | Motivo mostrado al usuario en el rechazo |
Bloqueo de retiros
coin-cashout.manager.js#requireApprovedForCashout(userId) lanza errors.identity_verification.not_approved a menos que el identity_verification_status del usuario sea approved. Se llama al inicio tanto de requestCashout (Stripe Connect) como de requestManualCashout (CLABE/RFC de México), así que ningún flujo de retiro puede iniciarse sin una identidad aprobada. Comprar monedas, recibir propinas/suscripciones y vender productos de la tienda no están bloqueados — solo convertir monedas de vuelta a dinero real.