Skip to content

Endpoints Detallados - Portal de Clientes

Estado: Planificado

Documentacion detallada de cada endpoint con schemas de request y response.

Autenticacion

POST /portal/auth/register

Auto-registro de un cliente existente en ordcon. El DNI/CUIT debe coincidir con un registro en la tabla ordcon del tenant.

Request:

json
{
  "identifier": "12345678",
  "identifier_type": "dni",
  "email": "juan@example.com",
  "password": "SecurePass123!",
  "password_confirmation": "SecurePass123!",
  "tenant_id": 1,
  "sucursal_id": 1
}
CampoTipoRequeridoDescripcion
identifierstringSiDNI o CUIT del cliente
identifier_typestringSidni o cuit
emailstringSiEmail para comunicaciones y recuperacion
passwordstringSiPassword (minimo 8 caracteres, al menos 1 numero)
password_confirmationstringSiConfirmacion del password
tenant_idintegerSiID del tenant (desde .env del frontend)
sucursal_idintegerSiID de la sucursal (desde .env del frontend)

Response 201:

json
{
  "success": true,
  "data": {
    "portal_user_id": "550e8400-e29b-41d4-a716-446655440000",
    "nombre": "Juan Perez",
    "email": "juan@example.com"
  }
}

Errores:

CodigocodeCausa
404ORDCON_NOT_FOUNDDNI/CUIT no existe en ordcon del tenant
409USER_ALREADY_EXISTSYa existe un portal_user para este ordcon
422INVALID_SUCURSALLa sucursal no pertenece al tenant
422VALIDATION_ERRORDatos invalidos (password debil, email invalido)

Flujo:

mermaid
sequenceDiagram
    participant F as Frontend
    participant C as AuthController
    participant S as PortalAuthService
    participant DB as PostgreSQL

    F->>C: POST /portal/auth/register
    C->>C: Validar request (estructura)
    C->>S: register(data)
    S->>DB: Validar sucursal pertenece a tenant
    S->>DB: Buscar ordcon por DNI/CUIT en schema del tenant
    alt ordcon no encontrado
        S-->>C: Error ORDCON_NOT_FOUND
    end
    S->>DB: Verificar no exista portal_user para este ordcon
    alt ya existe
        S-->>C: Error USER_ALREADY_EXISTS
    end
    S->>S: Hash password (bcrypt)
    S->>DB: INSERT portal_users
    S-->>C: portal_user creado
    C-->>F: 201 Created

POST /portal/auth/login

Autenticacion con credenciales. El frontend envia tenant_id y sucursal_id desde su configuracion (.env).

Request:

json
{
  "identifier": "12345678",
  "identifier_type": "dni",
  "password": "SecurePass123!",
  "tenant_id": 1,
  "sucursal_id": 1
}
CampoTipoRequeridoDescripcion
identifierstringSiDNI o CUIT del cliente
identifier_typestringSidni o cuit
passwordstringSiPassword del usuario
tenant_idintegerSiID del tenant (desde .env del frontend)
sucursal_idintegerSiID de la sucursal (desde .env del frontend)

Response 200:

json
{
  "success": true,
  "data": {
    "access_token": "eyJhbGciOiJIUzI1NiIs...",
    "refresh_token": "550e8400-e29b-41d4-a716-446655440000",
    "expires_in": 3600,
    "user": {
      "portal_user_id": "550e8400-e29b-41d4-a716-446655440000",
      "nombre": "Juan Perez",
      "email": "juan@example.com"
    }
  }
}

El refresh_token es un UUID almacenado en portal_users. Solo hay UNA sesion activa por usuario: un nuevo login sobreescribe el refresh token anterior.

JWT Payload:

json
{
  "portal_user_id": "550e8400-e29b-41d4-a716-446655440000",
  "tenant_id": 1,
  "sucursal_id": 1,
  "iat": 1712678400,
  "exp": 1712682000
}

El JWT NO contiene nombres de base de datos ni schemas. Solo IDs que el backend resuelve internamente:

  • tenant_id -> base de datos via ini.sistema
  • sucursal_id -> schema (sucXXXX, o public si ordcon es a nivel empresa)

Errores:

CodigocodeCausa
401INVALID_CREDENTIALSDNI/CUIT o password incorrectos
403ACCOUNT_LOCKEDCuenta bloqueada por intentos fallidos
422INVALID_SUCURSALLa sucursal no pertenece al tenant

Flujo:

mermaid
sequenceDiagram
    participant F as Frontend
    participant C as AuthController
    participant S as PortalAuthService
    participant DB as PostgreSQL

    F->>C: POST /portal/auth/login
    C->>C: Validar request
    C->>S: login(data)
    S->>DB: Validar sucursal pertenece a tenant
    S->>DB: Resolver DB y schema desde tenant_id/sucursal_id
    S->>DB: Buscar portal_user por DNI/CUIT
    alt no encontrado
        S-->>C: Error INVALID_CREDENTIALS
    end
    S->>S: Verificar no este bloqueado (locked_until)
    alt bloqueado
        S-->>C: Error ACCOUNT_LOCKED
    end
    S->>S: Verificar password_hash (bcrypt)
    alt password incorrecto
        S->>DB: Incrementar failed_attempts
        alt >= 5 intentos
            S->>DB: Establecer locked_until = now + 15 min
        end
        S-->>C: Error INVALID_CREDENTIALS
    end
    S->>DB: Resetear failed_attempts, actualizar last_login
    S->>S: Generar JWT (portal_user_id, tenant_id, sucursal_id)
    S->>S: Generar refresh_token
    S-->>C: tokens + user data
    C-->>F: 200 OK

POST /portal/auth/forgot-password

Solicita un codigo de recuperacion de password. Se envia por email al email registrado del portal_user.

Request:

json
{
  "identifier": "12345678",
  "identifier_type": "dni",
  "tenant_id": 1,
  "sucursal_id": 1
}

Response 200:

json
{
  "success": true,
  "data": {
    "message": "Si el usuario existe, se envio un codigo al email registrado"
  }
}

La respuesta es siempre exitosa para no revelar si el usuario existe o no.

Comportamiento interno:

  1. Buscar portal_user por DNI/CUIT en el tenant
  2. Si existe, generar codigo numerico de 6 digitos
  3. Guardar codigo con expiracion (15 minutos) en portal_users
  4. Enviar email con el codigo
  5. Si no existe, no hacer nada (respuesta identica)

POST /portal/auth/reset-password

Restablece el password usando el codigo enviado por email.

Request:

json
{
  "identifier": "12345678",
  "identifier_type": "dni",
  "code": "482951",
  "password": "NewSecurePass456!",
  "password_confirmation": "NewSecurePass456!",
  "tenant_id": 1,
  "sucursal_id": 1
}

Response 200:

json
{
  "success": true,
  "data": {
    "message": "Password actualizado correctamente"
  }
}

Errores:

CodigocodeCausa
400INVALID_CODECodigo incorrecto o expirado
422VALIDATION_ERRORPassword no cumple requisitos

POST /portal/auth/refresh-token

Renueva el access JWT usando el refresh token (UUID). Permite mantener la sesion sin re-login. Genera un nuevo par access_token + refresh_token (rotacion).

Request:

json
{
  "refresh_token": "550e8400-e29b-41d4-a716-446655440000"
}

No requiere JWT en el header Authorization. El refresh token es suficiente para identificar al usuario.

Response 200:

json
{
  "success": true,
  "data": {
    "access_token": "eyJhbGciOiJIUzI1NiIs...",
    "refresh_token": "nuevo-uuid-generado",
    "expires_in": 3600
  }
}

Flujo interno:

  1. Buscar portal_users por refresh_token (UUID)
  2. Verificar que refresh_token_expires > NOW()
  3. Verificar que el usuario no este bloqueado
  4. Generar nuevo access JWT con { portal_user_id, tenant_id, sucursal_id }
  5. Generar nuevo UUID de refresh token
  6. UPDATE portal_users SET refresh_token = nuevo_uuid, refresh_token_expires = NOW() + duracion
  7. Retornar nuevo par de tokens

Errores:

CodigocodeCausa
401INVALID_REFRESH_TOKENRefresh token no encontrado, expirado, o usuario bloqueado

Perfil

GET /portal/perfil

Datos del perfil del usuario autenticado. Merge de datos de ordcon (identidad) y portal_users (contacto/sesion).

Headers: Authorization: Bearer {jwt_token}

Response 200:

json
{
  "success": true,
  "data": {
    "nombre": "Juan Perez",
    "dni_cuit": "12345678",
    "email": "juan@example.com",
    "telefono": "1155443322",
    "last_login": "2026-04-08T14:30:00Z"
  }
}

Origen de los campos:

CampoFuenteEditable
nombreordcon (cnom)No (solo admin)
dni_cuitordcon (ccui) / portal_usersNo (solo admin)
emailportal_usersSi
telefonoportal_usersSi
last_loginportal_usersNo (automatico)

PUT /portal/perfil

Actualiza datos de contacto del perfil. Solo email y telefono son editables.

Headers: Authorization: Bearer {jwt_token}

Request:

json
{
  "email": "nuevo@example.com",
  "telefono": "1166554433"
}
CampoTipoRequeridoDescripcion
emailstringNoNuevo email (debe tener formato valido)
telefonostringNoNuevo telefono

Al menos uno de los campos debe estar presente.

Response 200:

json
{
  "success": true,
  "data": {
    "nombre": "Juan Perez",
    "dni_cuit": "12345678",
    "email": "nuevo@example.com",
    "telefono": "1166554433",
    "last_login": "2026-04-08T14:30:00Z"
  }
}

Errores:

CodigocodeCausa
422VALIDATION_ERROREmail con formato invalido o ningun campo enviado

PUT /portal/auth/cambiar-password

Cambio de password del usuario autenticado. Requiere verificacion del password actual.

Headers: Authorization: Bearer {jwt_token}

Request:

json
{
  "current_password": "MiPasswordActual123",
  "new_password": "NuevoPassword456"
}
CampoTipoRequeridoDescripcion
current_passwordstringSiPassword actual para verificacion
new_passwordstringSiNuevo password (minimo 8 caracteres, al menos 1 numero)

Response 200:

json
{
  "success": true,
  "data": {
    "message": "Password actualizado correctamente"
  }
}

Errores:

CodigocodeCausa
401INVALID_PASSWORDEl password actual no es correcto
422VALIDATION_ERROREl nuevo password no cumple politica (8 chars + 1 numero)

Flujo:

  1. Extraer portal_user_id del JWT
  2. Buscar portal_user en la base
  3. Verificar current_password contra password_hash (bcrypt_verify)
  4. Si no coincide -> Error INVALID_PASSWORD
  5. Validar que new_password cumple politica: minimo 8 caracteres + al menos 1 numero
  6. Hash nuevo password con bcrypt
  7. UPDATE portal_users SET password_hash = nuevo_hash

Cuenta Corriente

GET /portal/mi-cuenta

Resumen del estado de cuenta del usuario autenticado. El portal_user_id se extrae del JWT.

Headers: Authorization: Bearer {jwt_token}

Response 200:

json
{
  "success": true,
  "data": {
    "nombre": "Juan Perez",
    "saldo_total": 15000.00,
    "facturas_vencidas": 3,
    "facturas_pendientes": 5,
    "ultimo_pago": {
      "fecha": "2026-01-15",
      "monto": 5000.00
    }
  }
}

GET /portal/deudas

Listado completo de deudas pendientes del usuario autenticado.

Headers: Authorization: Bearer {jwt_token}

Response 200:

json
{
  "success": true,
  "data": [
    {
      "id": "uuid-1234",
      "tipo": "Factura A",
      "numero": 123,
      "fecha": "2026-01-01",
      "vencimiento": "2026-01-31",
      "monto": 10000.00,
      "saldo": 10000.00,
      "dias_vencido": 5,
      "esta_vencido": true
    }
  ]
}

Pagos

POST /portal/pagos/iniciar

Inicia un pago online. Crea un registro en portal_payments y obtiene la URL de checkout del gateway. Soporta seleccion libre de facturas con montos parciales.

Headers: Authorization: Bearer {jwt_token}

Request:

json
{
  "facturas": [
    {"id": "uuid-1234", "monto": 10000.00},
    {"id": "uuid-5678", "monto": 3000.00}
  ],
  "total": 13000.00
}
CampoTipoRequeridoDescripcion
facturasarraySiFacturas a pagar con sus montos
facturas[].idstring (UUID)SiID de la factura
facturas[].montodecimalSiMonto a pagar. Puede ser parcial: debe ser > 0 y <= factura.saldo
totaldecimalSiSuma de todos los montos de facturas

Pago parcial: El campo monto de cada factura puede ser menor que el saldo total de la factura. El recibo cubre el monto parcial; el saldo restante queda como deuda pendiente. Ejemplo: una factura con saldo $10.000 puede pagarse con monto $3.000, dejando $7.000 pendientes.

No se envia cliente_id en el body. Se obtiene del JWT via portal_user_id -> ordcon.

No se envia gateway en el request. El gateway se resuelve automaticamente desde la configuracion del tenant (ini.sistema.payment_gateway). El campo payment_method en la respuesta refleja que gateway se uso.

Response 200:

json
{
  "success": true,
  "data": {
    "payment_id": "uuid-payment-123",
    "redirect_url": "https://checkout.gateway.com/...",
    "payment_method": "paypertic"
  }
}

Validaciones:

  1. Cada factura debe existir en el schema del tenant
  2. Cada factura debe pertenecer al cliente autenticado (via ordcon)
  3. Cada monto debe ser > 0
  4. Cada monto debe ser <= factura.saldo (no se puede pagar mas del saldo pendiente)
  5. total debe coincidir con la suma de los monto de todas las facturas
  6. El tenant debe tener un gateway de pago configurado

Errores:

CodigocodeCausa
400INVALID_FACTURASFacturas invalidas, no pertenecen al cliente, o monto excede saldo
400MONTO_MISMATCHTotal no coincide con suma de montos de facturas
422GATEWAY_NOT_CONFIGUREDTenant no tiene gateway de pago configurado

POST /portal/pagos/webhook

Recibe notificaciones del gateway de pago. Endpoint publico (no requiere JWT), validado por firma del gateway.

Gateway-agnostic: Este endpoint usa la misma URL para todos los gateways. No se necesita un endpoint por gateway. La resolucion del adapter se hace internamente: se busca portal_payments por external_id del payload, y el campo gateway de la fila indica que adapter usar para validar y procesar el webhook.

Headers:

  • x-signature: Firma del webhook (validacion de seguridad, formato variable por gateway)
  • x-request-id: ID unico del request (idempotencia)

Request: Variable segun gateway. Cada adapter parsea el formato nativo de su gateway.

Response 200:

json
{
  "success": true
}

Resolucion de tenant en webhooks: El webhook no lleva JWT ni informacion de tenant. La resolucion se hace mediante metadata almacenada en portal_payments:

  1. El gateway envia el external_id del pago (referencia externa)
  2. El backend busca en portal_payments (tabla global o por busqueda secuencial) por external_id
  3. La fila de portal_payments contiene tenant_id y sucursal_id (almacenados al crear el pago en POST /portal/pagos/iniciar)
  4. Con tenant_id se resuelve la DB via ini.sistema; con sucursal_id se resuelve el schema
  5. Se procesa el webhook en el contexto del tenant correcto

No se usan query params en la URL del webhook ni resolucion por dominio.

Flujo automatico de acreditacion:

mermaid
sequenceDiagram
    participant GW as Gateway
    participant C as PagosController
    participant S as PaymentGatewayService
    participant R as ReciboRelationsService
    participant DB as PostgreSQL

    GW->>C: POST /portal/pagos/webhook
    C->>S: procesarWebhook(gateway, headers, body)
    S->>S: Validar firma del webhook
    S->>DB: Buscar portal_payment por external_id
    Note over S,DB: portal_payments tiene tenant_id + sucursal_id
    S->>DB: Resolver DB y schema desde tenant_id/sucursal_id
    S->>S: Verificar idempotencia (no procesar dos veces)
    alt pago aprobado
        S->>DB: UPDATE portal_payments SET status = 'approved'
        S->>R: crearRecibo(facturas, monto)
        R->>DB: INSERT en ordcta (recibo)
        S->>DB: UPDATE portal_payments SET recibo_id = {id}
    else pago rechazado
        S->>DB: UPDATE portal_payments SET status = 'rejected'
    end
    S-->>C: processed
    C-->>GW: 200 OK

Cuando el webhook confirma un pago aprobado, el servicio automaticamente crea un recibo en ctacte usando ReciboRelationsService. No requiere intervencion manual.


GET /portal/pagos/historial

Historial de pagos del usuario autenticado.

Headers: Authorization: Bearer {jwt_token}

Response 200:

json
{
  "success": true,
  "data": [
    {
      "id": "uuid-payment-123",
      "fecha": "2026-01-20",
      "metodo": "online",
      "monto": 15000.00,
      "estado": "approved",
      "facturas_pagadas": [
        {"tipo": "Factura A", "numero": 123, "monto": 10000.00}
      ],
      "recibo_numero": "REC-00123"
    }
  ]
}

POST /portal/pagos/cancelar

Cancela un pago pendiente. Solo se pueden cancelar pagos con status pending.

Headers: Authorization: Bearer {jwt_token}

Request:

json
{
  "payment_id": "uuid-payment-123"
}
CampoTipoRequeridoDescripcion
payment_idstring (UUID)SiID del portal_payment a cancelar

Response 200:

json
{
  "success": true,
  "data": {
    "payment_id": "uuid-payment-123",
    "status": "cancelled"
  }
}

Errores:

CodigocodeCausa
404PAYMENT_NOT_FOUNDEl payment_id no existe o no pertenece al usuario
409INVALID_STATUSEl pago no esta en estado pending
422GATEWAY_ERRORError al comunicar la cancelacion al gateway

POST /portal/pagos/devolver

Solicita la devolucion (reembolso) de un pago aprobado. Solo se pueden devolver pagos con status approved.

Headers: Authorization: Bearer {jwt_token}

Request:

json
{
  "payment_id": "uuid-payment-123"
}
CampoTipoRequeridoDescripcion
payment_idstring (UUID)SiID del portal_payment a devolver

Response 200:

json
{
  "success": true,
  "data": {
    "payment_id": "uuid-payment-123",
    "status": "refunded"
  }
}

Errores:

CodigocodeCausa
404PAYMENT_NOT_FOUNDEl payment_id no existe o no pertenece al usuario
409INVALID_STATUSEl pago no esta en estado approved
422GATEWAY_ERRORError al comunicar la devolucion al gateway

PDF de Cupones

GET /portal/cupones/{id}/pdf

Descarga el PDF de un cupon de pago. El backend actua como proxy: recibe el request del frontend, valida autenticacion y autorizacion, llama internamente al servicio de informes (puerto 9999), y retorna el PDF como stream.

El cliente nunca accede directamente al servicio de informes.

Headers: Authorization: Bearer {jwt_token}

Path Parameters:

ParametroTipoDescripcion
idstringID del cupon

Response 200:

Content-Type: application/pdf
Content-Disposition: attachment; filename="cupon-{id}.pdf"

<binary PDF data>

El frontend recibe el response como blob (responseType: 'blob' en Axios) y triggerea la descarga del archivo.

Errores:

CodigocodeCausa
404CUPON_NOT_FOUNDEl cupon no existe
403FORBIDDENEl cupon no pertenece al usuario autenticado
502INFORMES_SERVICE_ERRORError al comunicarse con el servicio de informes (puerto 9999)

Flujo:

mermaid
sequenceDiagram
    participant F as Frontend
    participant C as CuponController
    participant S as PortalCuponService
    participant I as Informes Service<br/>(puerto 9999)
    participant DB as PostgreSQL

    F->>C: GET /portal/cupones/{id}/pdf<br/>Authorization: Bearer {jwt}
    C->>C: Extraer portal_user_id del JWT
    C->>S: descargarPdf(cuponId, portalUserId)
    S->>DB: Buscar cupon por ID
    alt cupon no encontrado
        S-->>C: Error CUPON_NOT_FOUND
    end
    S->>S: Verificar cupon pertenece al usuario
    alt no pertenece
        S-->>C: Error FORBIDDEN
    end
    S->>I: GET http://localhost:9999/cupon/{id}
    alt servicio no disponible
        S-->>C: Error INFORMES_SERVICE_ERROR
    end
    I-->>S: PDF binary
    S-->>C: PDF stream
    C-->>F: 200 OK<br/>Content-Type: application/pdf<br/>Content-Disposition: attachment

Implementacion backend:

  • El controller recibe el request y valida JWT via middleware
  • El service verifica que el cupon existe y pertenece al usuario autenticado
  • El service realiza un HTTP GET interno a http://localhost:9999/cupon/{id} (servicio de informes)
  • El response del servicio de informes se retransmite como stream al cliente
  • No se almacena el PDF en disco ni en cache: cada request genera un PDF fresco

Implementacion frontend:

typescript
// lib/api/cupones.ts
export async function descargarPdf(cuponId: string): Promise<Blob> {
  const response = await apiClient.get(`/portal/cupones/${cuponId}/pdf`, {
    responseType: 'blob',
  })
  return response.data
}

Cupones

El sub-modulo Cupon delega a los servicios existentes CuponPagoService y CuponValidacionService del modulo CtaCte. NO existe tabla portal_cupones.

POST /portal/cupones/generar

Genera un cupon de pago con codigo de barras ITF. Delega a CuponPagoService existente.

Headers: Authorization: Bearer {jwt_token}

Request:

json
{
  "facturas": [
    {"id": "uuid-1234", "tipo": "Factura A", "numero": 123, "monto": 10000.00}
  ],
  "total": 10000.00,
  "dias_vencimiento": 30
}

No se envia cliente_id en el body. Se obtiene del JWT.

Response 200:

json
{
  "success": true,
  "data": {
    "cupon_id": "uuid-cupon-123",
    "codigo_barras": "0001056789202601274",
    "monto": 10000.00,
    "fecha_vencimiento": "2026-02-27",
    "facturas": [
      {"id": "uuid-1234", "tipo": "Factura A", "numero": 123, "monto": 10000.00}
    ]
  }
}

GET /portal/cupones/mis-cupones

Lista los cupones del usuario autenticado con filtros opcionales.

Headers: Authorization: Bearer {jwt_token}

Query Parameters:

ParametroTipoDefaultDescripcion
estadostring(todos)Filtrar: pending, used, expired, cancelled
limitinteger50Cantidad de registros (max 100)
offsetinteger0Offset para paginacion

Response 200:

json
{
  "success": true,
  "data": {
    "cupones": [
      {
        "cupon_id": "uuid-cupon-123",
        "codigo_barras": "0001056789202601274",
        "monto": 10000.00,
        "estado": "pending",
        "fecha_generacion": "2026-01-27",
        "fecha_vencimiento": "2026-02-27"
      }
    ],
    "total": 1,
    "has_more": false
  }
}

GET /portal/cupones/

Obtiene el detalle de un cupon especifico por su codigo de barras.

Headers: Authorization: Bearer {jwt_token}

Response 200:

json
{
  "success": true,
  "data": {
    "cupon_id": "uuid-cupon-123",
    "codigo_barras": "0001056789202601274",
    "monto": 10000.00,
    "estado": "pending",
    "fecha_generacion": "2026-01-27",
    "fecha_vencimiento": "2026-02-27",
    "facturas": [
      {"id": "uuid-1234", "tipo": "Factura A", "numero": 123, "monto": 10000.00}
    ]
  }
}

Errores:

CodigocodeCausa
404CUPON_NOT_FOUNDCodigo de barras no encontrado
403FORBIDDENEl cupon no pertenece al usuario autenticado