Appearance
Reenvío de Emails de Facturación — Documentación Técnica
Módulo: Membresias Feature: Reenvío de emails de facturación por lotes Fecha: 2026-03-19
Este documento documenta la arquitectura técnica de las 3 fases del reenvío de emails. Enfoque: Contratos de API, esquema de base de datos, estrategia de retry, y flujo de datos.
Endpoints REST
POST reenviar-emails
POST /mod-membresia/auditoria/lotes/{id}/reenviar-emails| Elemento | Detalle |
|---|---|
| Headers | X-Schema, Authorization |
| Body (opcional) | { socio_ids?: number[] } — omitir para resend total |
| Response 202 | { job_id: number, entries_count: number } |
| Errores | 404: Lote no encontrado 409: Job de email ya activo (dedup) 422: Lote en simulación / sin detalles / sin destinatarios / en progreso |
GET estado-emails
GET /mod-membresia/auditoria/lotes/{id}/emails?estado=error&pagina=1&por_pagina=50| Elemento | Detalle |
|---|---|
| Headers | X-Schema, Authorization |
| Query params | estado (opcional), pagina, por_pagina |
| Response 200 | { data: [...], total, pagina, por_pagina, resumen: {enviado, error, omitido} } |
Esquema de Base de Datos
Tabla: membresia_facturacion_email_detalle
Nivel: SUCURSAL (no EMPRESA ni CAJA — es por sucursal)
Nota multi-tenant: Sin FK física cross-schema. Referencia lógica en application layer.
sql
CREATE TABLE membresia_facturacion_email_detalle (
id SERIAL PRIMARY KEY,
lote_id INTEGER NOT NULL,
socio_id INTEGER NOT NULL,
email VARCHAR(255),
estado VARCHAR(20) NOT NULL DEFAULT 'pendiente'
CHECK (estado IN ('pendiente','enviado','error','omitido')),
intentos INTEGER NOT NULL DEFAULT 0,
ultimo_intento TIMESTAMPTZ,
error_detalle TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX idx_email_detalle_lote_estado ON membresia_facturacion_email_detalle(lote_id, estado);
CREATE UNIQUE INDEX idx_email_detalle_lote_socio ON membresia_facturacion_email_detalle(lote_id, socio_id);Notas de implementación:
- Multi-tenant vía
search_path(schema de sucursal) - Sin FK física: lotes cruzados entre schemas romperían constraints
- UPSERT en
(lote_id, socio_id)para evitar duplicados
Estrategia de Retry — Exponential Backoff
Interfaz
php
interface RetryStrategyInterface {
public function nextDelay(int $attempt): int; // segundos
public function shouldRetry(int $attempt, int $maxRetries): bool;
}Implementación: ExponentialBackoffStrategy
| Variable | Default | Descripción |
|---|---|---|
MAIL_RETRY_MAX | 5 | Máximo de reintentos |
MAIL_RETRY_BASE_DELAY | 60 | Delay base en segundos |
Fórmula:
delay = min(BASE_DELAY * 2^attempt + mt_rand(0, BASE_DELAY/2), 3600)Ejemplos por intento:
| Attempt | Delay |
|---|---|
| 0 | 60-90 segundos |
| 1 | 120-150 segundos |
| 2 | 240-270 segundos |
| 4 | 960-990 segundos |
| 5 | capped a 3600 (1 hora) |
Registro en DI: ExponentialBackoffStrategy para tipo email_notification_lote. Fallback para tipos sin estrategia registrada.
Flujo de Retry (Phase 3)
mermaid
sequenceDiagram
participant JobExecutor
participant RetryScheduler
participant Handler as EmailNotificationLoteJobHandler
participant EmailDetalle as EmailDetalleModel
JobExecutor->>JobExecutor: Detecta SmtpDeliveryAllFailedException
alt retry_count < maxRetries
JobExecutor->>JobExecutor: Consulta ExponentialBackoffStrategy.nextDelay(retry_count)
JobExecutor->>JobExecutor: Actualiza job.nextRetryAt = NOW() + delay
JobExecutor->>RetryScheduler: Schedules retry
RetryScheduler->>Handler: Re-dispacha job (retry_count > 0)
Handler->>EmailDetalle: getEnviadoSocioIds(loteId)
EmailDetalle-->>Handler: IDs de exitosos previos
Handler->>Handler: Filtra gruposExitosos excluyendo enviados
Handler->>Handler: Procesa solo pendientes/error
Handler->>EmailDetalle: upsertEntryResult() per entry
EmailDetalle-->>Handler: Confirma写入
else max retries exhausted
JobExecutor->>JobExecutor: No re-schedule, marca job como fallido
endNotas de Implementación
| Aspecto | Detalle |
|---|---|
| Reconstrucción de gruposExitosos | Se leen del JSONB detalles en tabla membresia_facturacion_lote_auditoria — no se almacenan separadamente |
Flag prueba | Se extrae de request_completo JSONB y override al parámetro original |
| Tracking backward-compatible | Si la tabla email_detalle no existe (Fase 1), el handler loguea warning y continúa sin tracking |
| Filtrado retry backward-compatible | Si getEnviadoSocioIds falla (DB error), se procesan todos los entries y se loguea warning |
Relación con Otros Documentos
| Documento | Relación |
|---|---|
membresia-facturacion-schema-technical-backend.md | Esquema técnico de la tabla principal de facturación |
../../../features/membresias/facturacion/facturacion-lotes-process.md | Proceso de negocio de facturación por lotes |