Appearance
Endpoints Detallados - Portal de Clientes
Estado: Implementado
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": {
"items": [
{
"id": "1f2e3d4c-...",
"comprobante": "Factura A",
"nrocomp": "0001-00000001",
"fecha": "2026-01-01",
"vencimiento": "2026-01-31",
"debe": 10000.00,
"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. Las facturas seleccionadas deben formar un prefijo contiguo del orden canónico de deudas pendientes (FIFO cronológico). Cada factura seleccionada se paga por el total pendiente.
Headers: Authorization: Bearer {jwt_token}
Request:
json
{
"facturas": [
{"id": 1234, "monto": 10000.00},
{"id": 5678, "monto": 3000.00}
],
"total": 13000.00
}| Campo | Tipo | Requerido | Descripcion |
|---|---|---|---|
| facturas | array | Si | Facturas seleccionadas para pagar con sus montos completos |
| facturas[].id | string (UUID) | Si | ID de la deuda/factura (ordcta.id) — es UUID desde la migración de Sept 2024 |
| facturas[].monto | decimal | Si | Monto a pagar. Debe coincidir con el total pendiente de esa factura (deuda.debe) |
| total | decimal | Si | Suma de todos los montos de facturas |
Pago parcial prohibido: El portal no permite pagar parcialmente una factura. Si una factura tiene deuda pendiente de $10.000, el request debe enviar monto: 10000.00. Un request con monto: 3000.00 se rechaza con PARTIAL_PAYMENT_NOT_ALLOWED.
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) - Cada
montodebe coincidir condeuda.debedentro de una tolerancia de0.001; montos parciales se rechazan totaldebe coincidir con la suma de losmontode todas las facturas- Orden canónico (FIFO): los IDs de facturas enviados deben ser exactamente los primeros K del array canónico de deudas pendientes del cliente, ordenado por
vencimiento ASCcon nulls al final. El orden dentro del payload no importa; solo importa que el conjunto de IDs sea un prefijo contiguo del canónico. No se puede pagar una deuda más nueva sin primero pagar todas las más antiguas. - El request debe incluir header
Origin, usado para construir el retorno post-pago - El backend debe tener
BACKEND_URLconfigurada para construir la URL publica del webhook - 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 |
| 400 | PARTIAL_PAYMENT_NOT_ALLOWED | Al menos una factura fue enviada con monto menor al total pendiente |
| 400 | MISSING_ORIGIN | Falta header Origin; no se puede construir return_url |
| 500 | MISCONFIGURED | Falta BACKEND_URL; no se puede construir notification_url |
| 422 | GATEWAY_NOT_CONFIGURED | Tenant no tiene gateway de pago configurado |
| 422 | RECIBO_NOT_CONFIGURED | Falta portal.recibo.cuenta_bancaria o portal.recibo.caja_schema — auto-reconciliación no puede operar |
| 422 | PAYMENT_ORDER_VIOLATION | Las facturas no forman un prefijo contiguo del orden canónico FIFO (cronológico) de deudas pendientes |
POST /portal/pagos/webhook
Recibe notificaciones del gateway de pago. Endpoint publico (no requiere JWT), validado por firma del gateway.
URL registrada en gateway:
text
{BACKEND_URL}/backend/portal/pagos/webhook?tenant_id={tenantId}&sucursal_id={sucursalId}&token={webhookToken}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. La resolucion se hace con los query params registrados en notification_url y se valida con el token configurado:
- El gateway llama la URL con
tenant_id,sucursal_idytoken - El backend valida el
tokencontra la configuracion del tenant - Con
tenant_idresuelve la DB viaini.sistema; consucursal_idresuelve el schema - Busca en
portal_paymentsporexternal_idpara verificar idempotencia y obtener datos completos
Si falta o no coincide el token, responde 401 y no modifica portal_payments. Si tenant_id o sucursal_id faltan o no son numericos, responde 400 y no modifica portal_payments.
Flujo de acreditacion:
mermaid
sequenceDiagram
participant GW as Gateway
participant C as PagosController
participant S as PortalPaymentService
participant DB as PostgreSQL
GW->>C: POST /backend/portal/pagos/webhook?tenant_id=...&sucursal_id=...&token=...
C->>S: procesarWebhook(headers, body)
S->>S: Validar token y query params
S->>DB: Resolver DB y schema
S->>DB: Buscar portal_payment por external_id
S->>S: Verificar idempotencia (no procesar dos veces)
alt pago aprobado
S->>DB: TX1: UPDATE portal_payments SET status = 'approved'
S->>DB: TX2: INSERT ordcta (recibo) + movimi (caja)
S->>DB: TX2: UPDATE portal_payments SET recibo_id, recibo_at
else pago rechazado
S->>DB: UPDATE portal_payments SET status = 'rejected'
end
S-->>C: processed
C-->>GW: 200 OKAuto-reconciliación: El webhook no solo actualiza el estado. Tras commitear TX1 (status = approved), dispara TX2 que genera el recibo en ordcta y el movimiento en caja automáticamente. TX2 es independiente de TX1 — un fallo de TX2 no revierte la aprobación. Ver auto-reconciliación técnico.
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"
}
]
}GET /backend/portal/pagos/{id}/recibo
Descarga el PDF del recibo de un pago aprobado. El backend hace ownership-check, consulta ordcta + recfac en el schema sucursal, delega la generación del PDF al servicio Informes (case "portal-recibo"), y retorna el binario como stream.
El cliente nunca accede directamente al servicio Informes.
Headers: Authorization: Bearer {jwt_token}
Path Parameters:
| Parametro | Tipo | Descripcion |
|---|---|---|
| id | integer | portal_payments.id del pago |
Response 200:
Content-Type: application/pdf
Content-Disposition: inline; filename="recibo-{numero}.pdf"
Cache-Control: no-store
<binary PDF data>El PDF se abre inline en el navegador. El frontend recibe el response como blob (responseType: 'blob') y usa window.open(URL.createObjectURL(blob)) para abrirlo en nueva pestaña.
Errores:
| Codigo | code | Causa |
|---|---|---|
| 401 | UNAUTHORIZED | JWT ausente o invalido |
| 403 | FORBIDDEN | El pago existe pero pertenece a otro cliente (ordcon_id del JWT no coincide) |
| 404 | PAYMENT_NOT_FOUND | No existe portal_payments con el ID dado |
| 409 | RECIBO_PENDIENTE | El pago esta aprobado pero recibo_id IS NULL (TX2 no completo aun) |
| 422 | RECIBO_NO_DISPONIBLE | El pago existe pero no esta en estado approved |
| 502 | INFORMES_ERROR | Error al comunicarse con el servicio Informes (timeout, 4xx, 5xx) |
Notas:
recibo_id IS NOT NULLenportal_paymentses la unica fuente de verdad para "recibo disponible"Cache-Control: no-storeevita que proxies cacheen PDFs de un cliente y los sirvan a otro- El
sucursal_idpara el cross-schema lookup viene siempre del JWT, nunca del body
Flujo:
mermaid
sequenceDiagram
participant F as Frontend
participant C as PortalPaymentController
participant S as PortalPaymentService
participant DB as PostgreSQL
participant I as Informes Service<br/>(puerto 9999)
F->>C: GET /backend/portal/pagos/{id}/recibo<br/>Authorization: Bearer {jwt}
C->>C: Extraer ordcon_id, sucursal_id, tenant_id del JWT
C->>S: getReciboPdf(paymentId, ordconId, sucursalId, tenantId)
S->>DB: SELECT portal_payments WHERE id=:id AND ordcon_id=:ordcon
alt no row
S-->>C: PaymentNotFoundException → 404
else status != approved
S-->>C: ReciboNoDisponibleException → 422
else recibo_id IS NULL
S-->>C: ReciboPendienteException → 409
end
S->>DB: setSearchPath(suc{sucursal_id})<br/>SELECT ordcta + recfac
S->>S: Resolver DB via IniSistemaRepo<br/>Emitir s2s JWT (InternalJwtIssuer)
S->>I: POST {URL_INFORMES} {codReporte:"portal-recibo", ...}
alt Informes OK
I-->>S: 200 application/pdf
S-->>C: [pdfBinary, numeroRecibo]
C-->>F: 200 application/pdf<br/>Content-Disposition: inline; filename="recibo-{numero}.pdf"
else Informes error
I-->>S: error / timeout
S-->>C: InformesUnavailableException → 502
endPOST /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 |