Appearance
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
}| Campo | Tipo | Requerido | Descripcion |
|---|---|---|---|
| identifier | string | Si | DNI o CUIT del cliente |
| identifier_type | string | Si | dni o cuit |
| string | Si | Email para comunicaciones y recuperacion | |
| password | string | Si | Password (minimo 8 caracteres, al menos 1 numero) |
| password_confirmation | string | Si | Confirmacion del password |
| tenant_id | integer | Si | ID del tenant (desde .env del frontend) |
| sucursal_id | integer | Si | ID 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:
| Codigo | code | Causa |
|---|---|---|
| 404 | ORDCON_NOT_FOUND | DNI/CUIT no existe en ordcon del tenant |
| 409 | USER_ALREADY_EXISTS | Ya existe un portal_user para este ordcon |
| 422 | INVALID_SUCURSAL | La sucursal no pertenece al tenant |
| 422 | VALIDATION_ERROR | Datos 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 CreatedPOST /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
}| Campo | Tipo | Requerido | Descripcion |
|---|---|---|---|
| identifier | string | Si | DNI o CUIT del cliente |
| identifier_type | string | Si | dni o cuit |
| password | string | Si | Password del usuario |
| tenant_id | integer | Si | ID del tenant (desde .env del frontend) |
| sucursal_id | integer | Si | ID 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 viaini.sistemasucursal_id-> schema (sucXXXX, opublicsi ordcon es a nivel empresa)
Errores:
| Codigo | code | Causa |
|---|---|---|
| 401 | INVALID_CREDENTIALS | DNI/CUIT o password incorrectos |
| 403 | ACCOUNT_LOCKED | Cuenta bloqueada por intentos fallidos |
| 422 | INVALID_SUCURSAL | La 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 OKPOST /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:
- Buscar portal_user por DNI/CUIT en el tenant
- Si existe, generar codigo numerico de 6 digitos
- Guardar codigo con expiracion (15 minutos) en portal_users
- Enviar email con el codigo
- 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:
| Codigo | code | Causa |
|---|---|---|
| 400 | INVALID_CODE | Codigo incorrecto o expirado |
| 422 | VALIDATION_ERROR | Password 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:
- Buscar
portal_usersporrefresh_token(UUID) - Verificar que
refresh_token_expires > NOW() - Verificar que el usuario no este bloqueado
- Generar nuevo access JWT con
{ portal_user_id, tenant_id, sucursal_id } - Generar nuevo UUID de refresh token
- UPDATE
portal_usersSETrefresh_token = nuevo_uuid,refresh_token_expires = NOW() + duracion - Retornar nuevo par de tokens
Errores:
| Codigo | code | Causa |
|---|---|---|
| 401 | INVALID_REFRESH_TOKEN | Refresh 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:
| Campo | Fuente | Editable |
|---|---|---|
| nombre | ordcon (cnom) | No (solo admin) |
| dni_cuit | ordcon (ccui) / portal_users | No (solo admin) |
| portal_users | Si | |
| telefono | portal_users | Si |
| last_login | portal_users | No (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"
}| Campo | Tipo | Requerido | Descripcion |
|---|---|---|---|
| string | No | Nuevo email (debe tener formato valido) | |
| telefono | string | No | Nuevo 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:
| Codigo | code | Causa |
|---|---|---|
| 422 | VALIDATION_ERROR | Email 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"
}| Campo | Tipo | Requerido | Descripcion |
|---|---|---|---|
| current_password | string | Si | Password actual para verificacion |
| new_password | string | Si | Nuevo password (minimo 8 caracteres, al menos 1 numero) |
Response 200:
json
{
"success": true,
"data": {
"message": "Password actualizado correctamente"
}
}Errores:
| Codigo | code | Causa |
|---|---|---|
| 401 | INVALID_PASSWORD | El password actual no es correcto |
| 422 | VALIDATION_ERROR | El nuevo password no cumple politica (8 chars + 1 numero) |
Flujo:
- Extraer
portal_user_iddel JWT - Buscar portal_user en la base
- Verificar
current_passwordcontrapassword_hash(bcrypt_verify) - Si no coincide -> Error INVALID_PASSWORD
- Validar que
new_passwordcumple politica: minimo 8 caracteres + al menos 1 numero - Hash nuevo password con bcrypt
- UPDATE
portal_usersSETpassword_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
}| Campo | Tipo | Requerido | Descripcion |
|---|---|---|---|
| facturas | array | Si | Facturas a pagar con sus montos |
| facturas[].id | string (UUID) | Si | ID de la factura |
| facturas[].monto | decimal | Si | Monto a pagar. Puede ser parcial: debe ser > 0 y <= factura.saldo |
| total | decimal | Si | Suma 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:
- Cada factura debe existir en el schema del tenant
- Cada factura debe pertenecer al cliente autenticado (via ordcon)
- Cada
montodebe ser > 0 - Cada
montodebe ser <=factura.saldo(no se puede pagar mas del saldo pendiente) totaldebe coincidir con la suma de losmontode todas las facturas- El tenant debe tener un gateway de pago configurado
Errores:
| Codigo | code | Causa |
|---|---|---|
| 400 | INVALID_FACTURAS | Facturas invalidas, no pertenecen al cliente, o monto excede saldo |
| 400 | MONTO_MISMATCH | Total no coincide con suma de montos de facturas |
| 422 | GATEWAY_NOT_CONFIGURED | Tenant 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:
- El gateway envia el
external_iddel pago (referencia externa) - El backend busca en
portal_payments(tabla global o por busqueda secuencial) porexternal_id - La fila de
portal_paymentscontienetenant_idysucursal_id(almacenados al crear el pago en POST /portal/pagos/iniciar) - Con
tenant_idse resuelve la DB viaini.sistema; consucursal_idse resuelve el schema - 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 OKCuando 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"
}| Campo | Tipo | Requerido | Descripcion |
|---|---|---|---|
| payment_id | string (UUID) | Si | ID del portal_payment a cancelar |
Response 200:
json
{
"success": true,
"data": {
"payment_id": "uuid-payment-123",
"status": "cancelled"
}
}Errores:
| Codigo | code | Causa |
|---|---|---|
| 404 | PAYMENT_NOT_FOUND | El payment_id no existe o no pertenece al usuario |
| 409 | INVALID_STATUS | El pago no esta en estado pending |
| 422 | GATEWAY_ERROR | Error 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"
}| Campo | Tipo | Requerido | Descripcion |
|---|---|---|---|
| payment_id | string (UUID) | Si | ID del portal_payment a devolver |
Response 200:
json
{
"success": true,
"data": {
"payment_id": "uuid-payment-123",
"status": "refunded"
}
}Errores:
| Codigo | code | Causa |
|---|---|---|
| 404 | PAYMENT_NOT_FOUND | El payment_id no existe o no pertenece al usuario |
| 409 | INVALID_STATUS | El pago no esta en estado approved |
| 422 | GATEWAY_ERROR | Error 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:
| Parametro | Tipo | Descripcion |
|---|---|---|
| id | string | ID 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:
| Codigo | code | Causa |
|---|---|---|
| 404 | CUPON_NOT_FOUND | El cupon no existe |
| 403 | FORBIDDEN | El cupon no pertenece al usuario autenticado |
| 502 | INFORMES_SERVICE_ERROR | Error 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: attachmentImplementacion 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:
| Parametro | Tipo | Default | Descripcion |
|---|---|---|---|
| estado | string | (todos) | Filtrar: pending, used, expired, cancelled |
| limit | integer | 50 | Cantidad de registros (max 100) |
| offset | integer | 0 | Offset 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:
| Codigo | code | Causa |
|---|---|---|
| 404 | CUPON_NOT_FOUND | Codigo de barras no encontrado |
| 403 | FORBIDDEN | El cupon no pertenece al usuario autenticado |