Skip to content

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
ElementoDetalle
HeadersX-Schema, Authorization
Body (opcional){ socio_ids?: number[] } — omitir para resend total
Response 202{ job_id: number, entries_count: number }
Errores404: 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
ElementoDetalle
HeadersX-Schema, Authorization
Query paramsestado (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

VariableDefaultDescripción
MAIL_RETRY_MAX5Máximo de reintentos
MAIL_RETRY_BASE_DELAY60Delay base en segundos

Fórmula:

delay = min(BASE_DELAY * 2^attempt + mt_rand(0, BASE_DELAY/2), 3600)

Ejemplos por intento:

AttemptDelay
060-90 segundos
1120-150 segundos
2240-270 segundos
4960-990 segundos
5capped 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
    end

Notas de Implementación

AspectoDetalle
Reconstrucción de gruposExitososSe leen del JSONB detalles en tabla membresia_facturacion_lote_auditoria — no se almacenan separadamente
Flag pruebaSe extrae de request_completo JSONB y override al parámetro original
Tracking backward-compatibleSi la tabla email_detalle no existe (Fase 1), el handler loguea warning y continúa sin tracking
Filtrado retry backward-compatibleSi getEnviadoSocioIds falla (DB error), se procesan todos los entries y se loguea warning

Relación con Otros Documentos

DocumentoRelación
membresia-facturacion-schema-technical-backend.mdEsquema técnico de la tabla principal de facturación
../../../features/membresias/facturacion/facturacion-lotes-process.mdProceso de negocio de facturación por lotes