import { WebhookService } from 'src/cartera/webhooks/webhook.service';
import {
  BadRequestException,
  ConflictException,
  HttpException,
  HttpStatus,
  Inject,
  Injectable,
  NotFoundException,
  Logger,
  Optional,
} from '@nestjs/common';
import { CobMoraEstado, Prisma } from '@prisma/client';
import { AuditService } from 'src/audit/audit.service';
import { ClientesService } from 'src/clientes/clientes.service';
import { envs } from 'src/config/envs';
import { PrismaService } from 'src/prisma/prisma.service';
import { formatDateResponse, formatDateTimeResponse } from 'src/utils/utilidades';
import { alcanceSucursalUsuario, filtroFiscalPorSucursal, filtroSucursalOperativo } from 'src/utils/alcance-sucursal';
import { logoEmisor, nombreFantasiaEmisor, sucursalUnicaDeDocumentos } from 'src/utils/emisor-comprobante';
import { ContabilidadIntegracionService } from 'src/contabilidad/services/integracion.service';
import { AsignacionFacturasService } from 'src/vendedores-cobradores/asignacion-facturas.service';
import { CreateReciboCobroDto, PreviewDistribucionDto } from './dto/create-cobro.dto';
import { UpdateReciboCobroDto } from './dto/update-cobro.dto';
import { InteresesMoratoriosService } from 'src/cobranzas/intereses-moratorios.service';
import { AutorizacionesService } from 'src/cobranzas/autorizaciones.service';
import { GestionMoraService } from 'src/cobranzas/gestion-mora.service';
import { PromesasService } from 'src/cobranzas/promesas.service';
import { ReciboCobroEstado } from 'src/recibos/recibos.enums';
import { calcularCronograma, PoliticaCobro } from './politica-cobro.util';
import { imputarCobro } from './imputacion';
import { cuentasAfectadas, saldoDeCuenta } from '../common/utils/saldo-cuenta.util';
import { cuotasConInteresHuerfano } from '../cobranzas/interes-manual';
import { ESTADOS_FACTURA_NO_COBRABLE_PRISMA, sqlFacturaCobrable } from 'src/utils/estados-factura';
import { InteresManualService } from '../cobranzas/interes-manual.service';

/**
 * Valor sentinela del filtro de caja: recibos sin sesión asociada (cobros mobile
 * de cobradores sin caja propia y recibos migrados). No es un uuid, así que no
 * puede colisionar con una caja real.
 */
export const SIN_CAJA_FILTRO = 'SIN_CAJA';

/**
 * Datos de la sesión de caja que necesita la auditoría del recibo: qué caja,
 * quién la abrió y en qué ventana estuvo abierta.
 */
const SESION_CAJA_AUDIT_SELECT = {
  id: true,
  estado: true,
  fecha_apertura: true,
  fecha_cierre: true,
  cajas: { select: { id: true, descripcion: true } },
  usuario: { select: { id: true, nombres: true, apellidos: true } },
} satisfies Prisma.sesiones_cajaSelect;

/**
 * Rendición asociada: es la contraparte de la caja para los cobros que entran
 * sin sesión (el cobrador externo rinde el efectivo después).
 */
const RENDICION_AUDIT_SELECT = {
  id: true,
  codigo: true,
  numero: true,
  estado: true,
  fecha_rendicion: true,
} satisfies Prisma.rendiciones_cobranzaSelect;

/**
 * Parsea fecha aceptando "YYYY-MM-DD" como mediodía local (evita drift por timezone)
 * o ISO completo como Date estándar. Devuelve undefined si el input es falsy.
 */
function parseFechaLocal(value?: string | null): Date | undefined {
  if (!value) return undefined;
  if (/^\d{4}-\d{2}-\d{2}$/.test(value)) return new Date(`${value}T12:00:00`);
  return new Date(value);
}

@Injectable()
export class CobrosService {
  private readonly logger = new Logger(CobrosService.name);
  constructor(
    private readonly prisma: PrismaService,
    private readonly auditService: AuditService,
    private readonly clientesService: ClientesService,
    private readonly contabilidadIntegracion: ContabilidadIntegracionService,
    private readonly asignacionFacturas: AsignacionFacturasService,
    private readonly interesesMoratorios: InteresesMoratoriosService,
    private readonly autorizaciones: AutorizacionesService,
    private readonly gestionMora: GestionMoraService,
    private readonly promesas: PromesasService,
    private readonly interesManual: InteresManualService,
    // Opcional: si la cola de cartera no está disponible, el cobro sigue
    // funcionando igual. Cualquier fallo de webhook es fire-and-forget.
    @Optional() @Inject(WebhookService) private readonly webhooks?: WebhookService,
  ) {}

  async create(
    createReciboCobroDto: CreateReciboCobroDto,
    userId: string,
    empresaId: string,
    // `soloSesionPropia`: cobros desde la app del cobrador, que nunca deben
    // imputarse a la caja de otro. Ver resolverSesionCajaParaCobro.
    opciones?: { soloSesionPropia?: boolean },
  ) {
    const { detalles, ...cabeceraData } = createReciboCobroDto;

    if (!detalles || detalles.length === 0) {
      throw new BadRequestException('Debe incluir al menos un detalle de cobro');
    }

    cabeceraData.sesion_caja_id = await this.resolverSesionCajaParaCobro(
      cabeceraData.sesion_caja_id,
      userId,
      empresaId,
      opciones?.soloSesionPropia ?? false,
    );

    // Validar que todas las cuotas existan y pertenezcan al cliente
    const cuotaIds = detalles.map((d) => d.factura_cuota_id);
    const cuotas = await this.prisma.factura_cuotas.findMany({
      where: { id: { in: cuotaIds } },
      include: {
        factura_cab: {
          select: { cliente_id: true, cobrador_id: true },
        },
      },
    });

    if (cuotas.length !== cuotaIds.length) {
      throw new BadRequestException('Una o más cuotas no existen');
    }

    // Validar que todas las cuotas pertenezcan al mismo cliente
    const clientesCuotas = [...new Set(cuotas.map((c) => c.factura_cab.cliente_id))];
    if (clientesCuotas.length > 1 || clientesCuotas[0] !== cabeceraData.cliente_id) {
      throw new BadRequestException('Todas las cuotas deben pertenecer al cliente seleccionado');
    }

    // Validar campos de cheque cuando el medio de pago es cheque
    const mediosPagoIds = [...new Set(detalles.map((d) => d.medio_pago_id))];
    const mediosPago = await this.prisma.medio_pago.findMany({
      where: { id: { in: mediosPagoIds } },
      select: { id: true, codigo: true },
    });
    const mediosMap = new Map(mediosPago.map((m) => [m.id, m.codigo]));
    for (const det of detalles) {
      if (mediosMap.get(det.medio_pago_id) === 2) {
        if (!det.numero_cheque?.trim())
          throw new BadRequestException('El número de cheque es obligatorio para pagos con cheque');
        if (!det.banco_emisor?.trim())
          throw new BadRequestException('El banco emisor es obligatorio para pagos con cheque');
        if (det.numero_cheque.length !== 8)
          throw new BadRequestException('El número de cheque debe tener 8 caracteres');
        if (det.banco_emisor.length < 4 || det.banco_emisor.length > 20)
          throw new BadRequestException('El banco emisor debe tener entre 4 y 20 caracteres');
      }
    }

    // Calcular monto total pagado y descuento total
    // monto_total = cuotas pagadas + mora cobrada (interés moratorio). Sin la mora
    // el total mostrado al cliente quedaría < a lo entregado y los reportes de
    // mora cobrada del mes/año (cobros.service.ts:3475+) no reflejarían el ingreso.
    const moraTotalCab = Number(cabeceraData.mora_total ?? 0);
    const montoTotal = detalles.reduce((sum, d) => sum + d.monto_pagado, 0) + moraTotalCab;
    const descuentoTotal = detalles.reduce((sum, d) => sum + (d.descuento_aplicado || 0), 0);

    // Auto-detectar cobrador: si el usuario logueado está vinculado a un vendedor/cobrador
    const cobradorVinculado = await this.prisma.vendedores_cobradores.findFirst({
      where: {
        usuario_id: userId,
        empresa_id: empresaId,
        active: true,
        tipo: { in: ['cobrador', 'ambos'] },
      },
      orderBy: { id: 'asc' },
      select: { id: true },
    });

    // Cobrador por defecto desde la factura.
    const cobradorDesdeFactura: string | null =
      cuotas.find((c) => c.factura_cab.cobrador_id)?.factura_cab.cobrador_id ?? null;

    // Crear recibo con detalles en una transacción
    const recibo = await this.prisma.$transaction(async (tx) => {
      // Buscar numeración activa para Recibo de pago (tipo_documento codigo=100)
      // usando la sucursal del cobro para determinar el punto de expedición
      const numeraciones = await tx.$queryRaw<
        Array<{
          id: string;
          numero_actual: number;
          numero_final: number | null;
          active: boolean;
          punto_expedicion_id: string;
        }>
      >`SELECT nd.id, nd.numero_actual, nd.numero_final, nd.active, nd.punto_expedicion_id
        FROM numeraciones_documento nd
        INNER JOIN tipo_documento td ON td.id = nd.tipo_documento_id
        INNER JOIN empresas_puntos_expedicion epe ON epe.id = nd.punto_expedicion_id
        WHERE td.codigo = 100
          AND nd.empresa_id = ${empresaId}::uuid
          AND epe.sucursal_id = ${cabeceraData.sucursal_id}::uuid
          AND nd.active = true
        ORDER BY nd.created_at ASC
        LIMIT 1
        FOR UPDATE`;

      if (numeraciones.length === 0) {
        throw new NotFoundException(
          'No se encontró una numeración activa para Recibo de pago. Configure una numeración con tipo de documento "Recibo de pago" (código 100) para esta sucursal.',
        );
      }

      const numeracion = numeraciones[0];

      if (numeracion.numero_final && numeracion.numero_actual >= numeracion.numero_final) {
        throw new ConflictException('Se alcanzó el número final de la numeración de recibos');
      }

      // Obtener datos del punto de expedición
      const puntoExpedicion = await tx.empresas_puntos_expedicion.findUnique({
        where: { id: numeracion.punto_expedicion_id },
        include: {
          empresas_sucursales: {
            select: { punto_establecimiento: true },
          },
        },
      });

      const dest = puntoExpedicion?.empresas_sucursales?.punto_establecimiento || '001';
      const dpunexp = puntoExpedicion?.punto_expedicion || '001';

      // Defensa: contador puede estar desincronizado vs recibos_cobro existentes
      // (datos migrados de rec_multi, importaciones). Saltar hasta número libre.
      let candidato = numeracion.numero_actual;
      let numeroRecibo = `${dest}-${dpunexp}-${String(candidato).padStart(7, '0')}`;
      for (let i = 0; i < 10000; i++) {
        const existe = await tx.recibos_cobro.findFirst({
          where: { empresa_id: empresaId, numero_recibo: numeroRecibo },
          select: { id: true },
        });
        if (!existe) break;
        candidato += 1;
        if (numeracion.numero_final && candidato >= numeracion.numero_final) {
          throw new ConflictException('Se alcanzó el número final de la numeración de recibos');
        }
        numeroRecibo = `${dest}-${dpunexp}-${String(candidato).padStart(7, '0')}`;
      }

      // Incrementar número para el siguiente recibo
      await tx.numeraciones_documento.update({
        where: { id: numeracion.id },
        data: { numero_actual: candidato + 1 },
      });

      // Crear cabecera del recibo
      const nuevoRecibo = await tx.recibos_cobro.create({
        data: {
          empresa_id: empresaId,
          sucursal_id: cabeceraData.sucursal_id,
          sesion_caja_id: cabeceraData.sesion_caja_id,
          cliente_id: cabeceraData.cliente_id,
          usuario_id: userId,
          // Orden de prioridad para cobrador_id:
          //   1. El que el usuario eligió manualmente (cabeceraData.cobrador_id)
          //   2. El cobrador vinculado al usuario logueado
          //   3. El cobrador asignado a la factura (factura_cab.cobrador_id)
          //   4. null
          cobrador_id: cabeceraData.cobrador_id || cobradorVinculado?.id || cobradorDesdeFactura || null,
          fecha_emision: parseFechaLocal(cabeceraData.fecha_emision),
          fecha_registro: parseFechaLocal(cabeceraData.fecha_registro) ?? null,
          numero_recibo: numeroRecibo,
          monto_total: montoTotal,
          mora_total: moraTotalCab,
          monto_recibido: cabeceraData.monto_recibido ?? montoTotal,
          descuento_porcentaje: cabeceraData.descuento_porcentaje ?? null,
          descuento_monto: descuentoTotal > 0 ? descuentoTotal : (cabeceraData.descuento_monto ?? 0),
          motivo_descuento: cabeceraData.motivo_descuento ?? null,
          moneda_id: cabeceraData.moneda_id,
          cotizacion: cabeceraData.cotizacion,
          observacion: cabeceraData.observacion,
          estado: ReciboCobroEstado.EMITIDO,
        },
      });

      // Interés manual: el monto lo trae `interes_manual_cobrado` en la
      // cabecera, y sale de la imputación del preview (capital primero, el
      // interés se lleva lo que sobró del monto entregado). Nadie lo digita.
      // Va dentro de la TX: si el cobro falla, el cargo sigue pendiente.
      const interesACobrar = Number(cabeceraData.interes_manual_cobrado ?? 0);
      const interesCobrado =
        interesACobrar > 0
          ? await this.interesManual.cobrar(tx, empresaId, {
              cuotaIds,
              facturaIds: [...new Set(cuotas.map((c) => c.factura_cab_id))],
              reciboId: nuevoRecibo.id,
              monto: interesACobrar,
            })
          : { total: 0, porCuota: new Map<string, number>() };

      // Cuotas que este cobro deja saldadas: si alguna debe interés, el cargo
      // pasa a la cuenta al cerrar (ver cuotasConInteresHuerfano).
      const cuotasSaldadas: string[] = [];

      // Crear detalles del recibo
      for (const detalle of detalles) {
        // Determinar si es pago parcial
        const cuota = cuotas.find((c) => c.id === detalle.factura_cuota_id);
        const saldoCuota = cuota ? Number(cuota.saldo_pendiente || cuota.dmoncuota) : 0;
        const esParcial =
          detalle.es_pago_parcial ?? detalle.monto_pagado + (detalle.descuento_aplicado || 0) < saldoCuota;

        await tx.recibo_cobro_detalle.create({
          data: {
            recibo_cobro_id: nuevoRecibo.id,
            factura_cuota_id: detalle.factura_cuota_id,
            medio_pago_id: detalle.medio_pago_id,
            monto_original: detalle.monto_original ?? saldoCuota,
            monto_pagado: detalle.monto_pagado,
            descuento_aplicado: detalle.descuento_aplicado ?? 0,
            es_pago_parcial: esParcial,
            // Interés cobrado con esta cuota: la mora neta que trae el detalle
            // más el interés manual de esa cuota que este recibo cobró. El
            // interés de la cuenta no cuelga de ninguna: va en `mora_total`.
            mora_monto:
              Number(detalle.mora_monto ?? 0) + (interesCobrado.porCuota.get(detalle.factura_cuota_id) ?? 0),
            moneda_id: detalle.moneda_id,
            cotizacion: detalle.cotizacion,
            tarjeta_id: detalle.tarjeta_id,
            forma_procesamiento_pago_id: detalle.forma_procesamiento_pago_id,
            numero_tarjeta: detalle.numero_tarjeta,
            titular_tarjeta: detalle.titular_tarjeta,
            codigo_autorizacion: detalle.codigo_autorizacion,
            razon_social_procesadora: detalle.razon_social_procesadora,
            ruc_procesadora: detalle.ruc_procesadora,
            dv_procesadora: detalle.dv_procesadora,
            numero_cheque: detalle.numero_cheque,
            banco_emisor: detalle.banco_emisor,
            banco_id: detalle.banco_id,
          },
        });

        // Actualizar saldo de la cuota (pago + descuento reducen el saldo)
        if (!cuota) continue;
        const saldoActual = Number(cuota.saldo_pendiente || cuota.dmoncuota);
        const montoAplicado = detalle.monto_pagado + (detalle.descuento_aplicado || 0);
        const nuevoSaldo = Math.max(0, saldoActual - montoAplicado);

        await tx.factura_cuotas.update({
          where: { id: detalle.factura_cuota_id },
          data: {
            saldo_pendiente: nuevoSaldo,
            estado: nuevoSaldo <= 0 ? 'pagado' : 'pendiente',
          },
        });
        if (nuevoSaldo <= 0) cuotasSaldadas.push(detalle.factura_cuota_id);
      }

      // Capital primero: una cuota puede quedar saldada debiendo interés. Ese
      // cargo pasa a la cuenta; si se quedara colgado de la cuota quedaría vivo
      // pero incobrable, porque las cuotas pagadas no se pueden seleccionar.
      if (cuotasSaldadas.length > 0) {
        const cargosVivos = await tx.cob_interes_manual.findMany({
          where: {
            empresa_id: empresaId,
            estado: 'pendiente',
            factura_cuota_id: { in: cuotasSaldadas },
          },
          select: { factura_cuota_id: true, monto: true, monto_cobrado: true, estado: true },
        });
        const huerfanas = cuotasConInteresHuerfano(cuotasSaldadas, cargosVivos as any);
        if (huerfanas.length > 0) {
          await this.interesManual.liberarDeCuotas(tx, empresaId, huerfanas, { soloPendientes: true });
        }
      }

      // Actualizar saldo de cuenta por cobrar si existe
      const cuentasCobrar = await tx.cuentas_cobrar.findMany({
        where: {
          cliente_id: cabeceraData.cliente_id,
          empresa_id: empresaId,
          estado: { not: 'pagado' },
        },
      });

      for (const cuenta of cuentasCobrar) {
        const cuotasCuenta = await tx.factura_cuotas.findMany({
          where: { cuenta_id: cuenta.id },
        });

        const saldoTotal = cuotasCuenta.reduce((sum, c) => sum + Number(c.saldo_pendiente || c.dmoncuota), 0);

        await tx.cuentas_cobrar.update({
          where: { id: cuenta.id },
          data: {
            saldo_pendiente: saldoTotal,
            estado: saldoTotal <= 0 ? 'pagado' : 'pendiente',
          },
        });
      }

      // Registrar movimientos de caja (uno por medio de pago) si hay sesión activa
      if (cabeceraData.sesion_caja_id) {
        const sesion = await tx.sesiones_caja.findFirst({
          where: { id: cabeceraData.sesion_caja_id, estado: 'ABIERTA' },
        });

        if (sesion) {
          const medioPagoEfectivo = await tx.medio_pago.findFirst({
            where: { codigo: 1 },
          });
          const tipoMov = await tx.tipo_movimiento_cajas.findFirst({
            where: {
              OR: [
                { descripcion: { contains: 'Cobro', mode: 'insensitive' } },
                { descripcion: { contains: 'Entrada', mode: 'insensitive' } },
              ],
            },
          });

          // Agrupar montos por medio de pago
          const montosPorMedio = new Map<string, { monto: number; descMedio: string; codigo: number | null }>();
          for (const d of detalles) {
            if (!d.medio_pago_id) continue;
            const prev = montosPorMedio.get(d.medio_pago_id) || { monto: 0, descMedio: '', codigo: null };
            prev.monto += d.monto_pagado;
            montosPorMedio.set(d.medio_pago_id, prev);
          }
          // Resolver descripciones de medios en lote
          if (montosPorMedio.size > 0) {
            const medios = await tx.medio_pago.findMany({
              where: { id: { in: Array.from(montosPorMedio.keys()) } },
              select: { id: true, descripcion: true, codigo: true },
            });
            for (const m of medios) {
              const entry = montosPorMedio.get(m.id);
              if (entry) {
                entry.descMedio = m.descripcion || '';
                entry.codigo = m.codigo ?? null;
              }
            }
          }

          let montoEfectivo = 0;
          let montoTarjeta = 0;
          let montoTransferencia = 0;
          for (const [, { monto, descMedio, codigo }] of montosPorMedio) {
            if (monto <= 0) continue;
            await tx.movimiento_cajas.create({
              data: {
                sesion_id: sesion.id,
                empresa_id: empresaId,
                tipo_movimiento_caja_id: tipoMov?.id,
                monto,
                descripcion: `Cobro cuota - Recibo ${numeroRecibo}${descMedio ? ` - ${descMedio}` : ''}`,
                referencia: nuevoRecibo.id,
                usuario_id: userId,
                fecha_mov: new Date().toISOString(),
              },
            });
            if (codigo === 1) montoEfectivo += monto;
            else if (codigo === 3 || codigo === 4 || codigo === 8) montoTarjeta += monto;
            else if (codigo === 5) montoTransferencia += monto;
          }

          // Acumular contadores de la sesión por medio (refleja en card Caja del Día).
          // Efectivo va a monto_entradas_manual (como cobro). Tarjeta/transferencia van a sus
          // respectivos contadores para que figuren en la card, pero NO afectan saldo en caja
          // (se concilian por extracto bancario / liquidación).
          if (montoEfectivo > 0 || montoTarjeta > 0 || montoTransferencia > 0) {
            // Incremento atómico (ver mismo fix en facturas.service.ts): evita
            // que dos cobros de la misma sesión, procesados casi al mismo
            // tiempo, se pisen entre sí y "pierdan" un monto del contador.
            await tx.sesiones_caja.update({
              where: { id: sesion.id },
              data: {
                monto_ventas_tarjeta: { increment: montoTarjeta },
                monto_ventas_transferencia: { increment: montoTransferencia },
                monto_entradas_manual: { increment: montoEfectivo },
                updated_at: new Date(),
              },
            });
          }
        }
      }

      return nuevoRecibo;
    });

    // Fase 2: Reducir saldo pendiente del cliente tras cobro exitoso
    if (montoTotal > 0) {
      try {
        await this.clientesService.actualizarSaldoPendiente(
          cabeceraData.cliente_id,
          montoTotal,
          'restar',
          empresaId,
          userId,
        );
      } catch (err) {
        this.logger.warn(
          `No se pudo actualizar saldo crédito del cliente ${cabeceraData.cliente_id}: ${err}`,
          'CobrosService',
        );
      }
    }

    // Hook gestión de mora — fire and forget. Si el cliente quedó en saldo 0,
    // las gestiones activas pasan a RECUPERADA automáticamente.
    this.gestionMora
      .autoRecuperarPorCliente(cabeceraData.cliente_id, empresaId, userId)
      .then((r) => {
        if (r.transitioned > 0) {
          this.logger.log(
            `Gestión de mora auto-cerrada (RECUPERADA): ${r.transitioned} gestión(es) del cliente ${cabeceraData.cliente_id}`,
            'CobrosService',
          );
        }
      })
      .catch((err) => {
        this.logger.warn(
          `Hook gestión mora falló para cliente ${cabeceraData.cliente_id}: ${err.message}`,
          'CobrosService',
        );
      });

    // Hook promesas — fire & forget. Cierra promesas vigentes del cliente sobre las facturas/cuotas cobradas.
    {
      const facturaIds = [...new Set(cuotas.map((c) => c.factura_cab_id))];
      const cuotaIds = cuotas.map((c) => c.id);
      const montoTotal = Number(recibo.monto_total ?? 0);
      this.promesas
        .cerrarPorCobro({
          empresa_id: empresaId,
          cliente_id: cabeceraData.cliente_id,
          factura_cab_ids: facturaIds,
          cuota_ids: cuotaIds,
          recibo_cob_id: recibo.id,
          monto_cobrado: montoTotal,
        })
        .catch((err) => this.logger.warn(`Hook promesas cierre falló: ${err.message}`, 'CobrosService'));
    }

    const result = await this.findOne(recibo.id, empresaId);
    this.logger.log(`Recibo de cobro creado: ${recibo.numero_recibo} (${recibo.id})`, 'CobrosService');
    await this.auditService.log({
      empresa_id: empresaId,
      user_id: userId,
      action: 'CREATE',
      entity_type: 'recibo_cobro',
      entity_id: recibo.id,
      descripcion: `Recibo de cobro emitido: ${recibo.numero_recibo} | Monto: ${recibo.monto_total}`,
      new_value: {
        numero_recibo: recibo.numero_recibo,
        monto_total: Number(recibo.monto_total),
        cliente_id: recibo.cliente_id,
      },
    });

    // Hook contable — fire and forget
    this.contabilidadIntegracion.integrarCobro(result.id).catch((err) => {
      this.logger.error?.(`Error contabilizando cobro ${result.id}: ${err.message}`);
    });

    // Hook comisión de cobranza — fire and forget, con auditoría del resultado
    this.asignacionFacturas
      .generarComisionCobranzaPorRecibo(recibo.id, empresaId)
      .then(async (res) => {
        if (res?.created) {
          this.logger.log(
            `Comisión de cobranza generada para recibo ${recibo.numero_recibo} (${recibo.id}) | Comisión: ${res.comision_id} | Monto: ${res.monto_comision}`,
            'CobrosService',
          );
          await this.auditService.log({
            empresa_id: empresaId,
            user_id: userId,
            action: 'CREATE',
            entity_type: 'comision_cobranza',
            entity_id: res.comision_id!,
            descripcion: `Comisión de cobranza generada automáticamente | Recibo: ${recibo.numero_recibo} | Monto: ${res.monto_comision}`,
            new_value: { recibo_id: recibo.id, comision_id: res.comision_id, monto_comision: res.monto_comision },
          });
        } else {
          this.logger.log(
            `Comisión de cobranza NO generada para recibo ${recibo.numero_recibo} (${recibo.id}) | Motivo: ${res?.reason}`,
            'CobrosService',
          );
          await this.auditService.log({
            empresa_id: empresaId,
            user_id: userId,
            action: 'SKIP',
            entity_type: 'comision_cobranza',
            entity_id: recibo.id,
            descripcion: `Comisión de cobranza NO generada | Recibo: ${recibo.numero_recibo} | Motivo: ${res?.reason}`,
            new_value: { recibo_id: recibo.id, motivo: res?.reason },
          });
        }
      })
      .catch(async (err) => {
        this.logger.error?.(`Error generando comisión de cobranza para recibo ${recibo.id}: ${err.message}`);
        await this.auditService
          .log({
            empresa_id: empresaId,
            user_id: userId,
            action: 'ERROR',
            entity_type: 'comision_cobranza',
            entity_id: recibo.id,
            descripcion: `Error al generar comisión de cobranza | Recibo: ${recibo.numero_recibo} | ${err.message}`,
            new_value: { recibo_id: recibo.id, error: err.message },
          })
          .catch(() => {});
      });

    // Webhook fire-and-forget — sólo si el módulo Cartera está disponible
    // y la empresa tiene endpoints suscritos a cobro.registrado.
    if (this.webhooks) {
      void this.webhooks
        .publicar(empresaId, 'cobro.registrado', {
          recibo_id: recibo.id,
          numero_recibo: recibo.numero_recibo,
          cliente_id: recibo.cliente_id,
          monto_total: recibo.monto_total,
          moneda_id: recibo.moneda_id,
          fecha: recibo.fecha_emision,
          usuario_id: userId,
        })
        .catch((err) => this.logger.warn(`Webhook cobro.registrado fallo: ${err?.message ?? err}`));
    }

    return result;
  }

  async findAll(
    empresaId: string,
    params?: {
      skip?: number;
      take?: number;
      clienteId?: string;
      estado?: string;
      fechaDesde?: Date;
      fechaHasta?: Date;
      cobradorId?: string;
      cajaId?: string; // uuid de caja, o SIN_CAJA_FILTRO para los cobros sin sesión
      busqueda?: string;
      modo?: string; // 'LEGACY' | 'MULTI' | undefined = ambos
      orderBy?: string;
      order?: 'asc' | 'desc';
    },
  ) {
    const {
      skip = 0,
      take = 20,
      clienteId,
      estado,
      fechaDesde,
      fechaHasta,
      cobradorId,
      cajaId,
      busqueda,
      modo,
      orderBy,
      order,
    } = params || {};

    const direction: 'asc' | 'desc' = order ?? 'desc';
    const orderByMap: Record<string, Prisma.recibos_cobroOrderByWithRelationInput[]> = {
      numero_recibo: [{ numero_recibo: direction }],
      modo: [{ modo: direction }, { created_at: 'desc' }],
      fecha_emision: [{ fecha_emision: direction }, { created_at: direction }],
      fecha: [{ fecha_emision: direction }, { created_at: direction }],
      cliente: [{ clientes: { personas: { razon_social: direction } } }, { created_at: 'desc' }],
      cobrador: [{ cobrador: { nombre: direction } }, { created_at: 'desc' }],
      monto_total: [{ monto_total: direction }, { created_at: 'desc' }],
      monto: [{ monto_total: direction }, { created_at: 'desc' }],
      mora_total: [{ mora_total: direction }, { created_at: 'desc' }],
      mora: [{ mora_total: direction }, { created_at: 'desc' }],
      estado: [{ estado: direction }, { created_at: 'desc' }],
    };
    const orderByClause: Prisma.recibos_cobroOrderByWithRelationInput[] = (orderBy && orderByMap[orderBy]) || [
      { fecha_emision: 'desc' },
      { created_at: 'desc' },
    ];

    const where: Prisma.recibos_cobroWhereInput = {
      empresa_id: empresaId,
    };

    if (clienteId) {
      where.cliente_id = clienteId;
    }

    if (estado) {
      where.estado = estado;
    }

    if (modo) {
      where.modo = modo;
    }

    if (fechaDesde || fechaHasta) {
      where.fecha_emision = {};
      if (fechaDesde) {
        where.fecha_emision.gte = fechaDesde;
      }
      if (fechaHasta) {
        const hastaFin = new Date(fechaHasta);
        hastaFin.setDate(hastaFin.getDate() + 1); // incluir todo el día hasta
        where.fecha_emision.lt = hastaFin;
      }
    }

    if (cobradorId) {
      where.cobrador_id = cobradorId;
    }

    // Auditoría: filtrar por la caja donde se cobró. SIN_CAJA_FILTRO aísla los
    // recibos sin sesión (mobile / migrados), que se rinden por rendiciones.
    if (cajaId === SIN_CAJA_FILTRO) {
      where.sesion_caja_id = null;
    } else if (cajaId) {
      where.sesiones_caja = { caja_id: cajaId };
    }

    if (busqueda) {
      const term = busqueda.trim();
      where.OR = [
        { numero_recibo: { contains: term, mode: 'insensitive' } },
        { clientes: { personas: { razon_social: { contains: term, mode: 'insensitive' } } } },
        { clientes: { personas: { ruc: { contains: term, mode: 'insensitive' } } } },
        { clientes: { personas: { nro_documento: { contains: term, mode: 'insensitive' } } } },
      ];
    }

    const [data, total] = await Promise.all([
      this.prisma.recibos_cobro.findMany({
        where,
        skip,
        take,
        orderBy: orderByClause,
        include: {
          clientes: {
            include: {
              personas: true,
            },
          },
          cobrador: {
            select: { id: true, nombre: true, apellido: true },
          },
          usuario: {
            select: { id: true, nombres: true, apellidos: true },
          },
          moneda: true,
          recibo_cobro_detalle: {
            include: {
              factura_cuotas: {
                include: {
                  factura_cab: {
                    select: {
                      id: true,
                      dnumdoc: true,
                      dest: true,
                      dpunexp: true,
                    },
                  },
                },
              },
              medio_pago: true,
            },
          },
          // Auditoría de caja (ver SESION_CAJA_AUDIT_SELECT).
          sesiones_caja: { select: SESION_CAJA_AUDIT_SELECT },
          rendiciones_cobranza: { select: RENDICION_AUDIT_SELECT },
          // Sub-tablas del flujo MULTI (vacías si modo='LEGACY')
          facturas_multi: { select: { id: true, factura_cab_id: true, monto_pagado: true } },
          retenciones_multi: { select: { id: true, tipo: true, monto: true } },
        },
      }),
      this.prisma.recibos_cobro.count({ where }),
    ]);

    const dataFormateada = data.map((recibo: any) => ({
      ...recibo,
      estado: recibo.estado,
      fecha_emision: formatDateResponse(recibo.fecha_emision, true),
      fecha_registro: formatDateResponse(recibo.fecha_registro, false),
      fecha_anulacion: formatDateResponse(recibo.fecha_anulacion, false),
      created_at: formatDateResponse(recibo.created_at, false),
      // Campos flat para tabla unificada
      cliente_razon_social: recibo.clientes?.personas?.razon_social ?? null,
      cliente_ruc: recibo.clientes?.personas?.ruc ?? null,
      cliente_nro_documento: recibo.clientes?.personas?.nro_documento ?? null,
      recibo_cobro_detalle: recibo.recibo_cobro_detalle.map((det: any) => ({
        ...det,
        factura_cuotas: det.factura_cuotas
          ? {
              ...det.factura_cuotas,
              dvenccuo: formatDateResponse(det.factura_cuotas.dvenccuo, true),
            }
          : null,
      })),
    }));

    return {
      data: dataFormateada,
      meta: {
        total,
        skip,
        take,
        pages: Math.ceil(total / take),
      },
    };
  }

  /**
   * Cajas que efectivamente aparecen en recibos de la empresa, para poblar el
   * filtro de auditoría del listado. Se listan solo las usadas (no el catálogo
   * completo de cajas) para que el selector no se llene de opciones vacías.
   * `sin_caja_total` habilita la opción "Sin caja" (cobros mobile / migrados).
   */
  async cajasConRecibos(empresaId: string) {
    const [sesiones, sinCajaTotal] = await Promise.all([
      this.prisma.sesiones_caja.findMany({
        where: { empresa_id: empresaId, recibos_cobro: { some: {} } },
        select: { caja_id: true, cajas: { select: { id: true, descripcion: true } } },
        distinct: ['caja_id'],
      }),
      this.prisma.recibos_cobro.count({
        where: { empresa_id: empresaId, sesion_caja_id: null },
      }),
    ]);

    const cajas = sesiones
      .map((ses) => ses.cajas)
      .filter((caja): caja is { id: string; descripcion: string | null } => !!caja)
      .sort((a, b) => (a.descripcion ?? '').localeCompare(b.descripcion ?? ''));

    return { cajas, sin_caja_total: sinCajaTotal };
  }

  async findOne(id: string, empresa_id: string) {
    const uuidRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
    if (!uuidRegex.test(id)) throw new Error(`ID inválido: ${id}`);
    const recibo = await this.prisma.recibos_cobro.findFirst({
      where: { id, empresa_id },
      include: {
        empresas: {
          select: { id: true, razon_social: true, ruc: true },
        },
        empresas_sucursales: {
          select: { id: true, descripcion: true, punto_establecimiento: true },
        },
        // Auditoría: en qué caja/sesión entró la plata. Null en cobros mobile
        // (cobrador sin caja propia), que se auditan por su rendición.
        sesiones_caja: { select: SESION_CAJA_AUDIT_SELECT },
        rendiciones_cobranza: { select: RENDICION_AUDIT_SELECT },
        clientes: {
          include: {
            personas: true,
          },
        },
        usuario: {
          select: { id: true, nombres: true, apellidos: true },
        },
        moneda: true,
        recibo_cobro_detalle: {
          include: {
            factura_cuotas: {
              include: {
                factura_cab: {
                  select: {
                    id: true,
                    dnumdoc: true,
                    dest: true,
                    dpunexp: true,
                    dfeemide: true,
                    dcuotas: true,
                  },
                },
              },
            },
            medio_pago: true,
            tarjeta: true,
            forma_procesamiento_pago: true,
            bancos: true,
            moneda: true,
          },
        },
      },
    });

    if (!recibo) {
      throw new NotFoundException(`Recibo de cobro con ID ${id} no encontrado`);
    }

    // A.3.9: adjuntar desglose de intereses moratorios cobrados por cuota
    // (necesario para atender reclamos del cliente: base, tasa, días, exoneración,
    // autorización usada y factura SET asociada).
    const interesesCobrados = await this.prisma.cob_interes_cobrado.findMany({
      where: { recibo_cobro_id: id },
      orderBy: { created_at: 'asc' },
    });

    // Enriquecer cada interés con el documento fiscal vinculado (si fue emitido)
    // para mostrarlo en el drawer del recibo.
    const facturaIds = interesesCobrados.map((i) => i.factura_cab_id).filter((v): v is string => !!v);
    const facturasVinculadas = facturaIds.length
      ? await this.prisma.factura_cab.findMany({
          where: { id: { in: facturaIds } },
          select: {
            id: true,
            dnumdoc: true,
            dest: true,
            dpunexp: true,
            estado_sifen: true,
            enlace_qr: true,
            total_factura: true,
          },
        })
      : [];
    const facturaMap = new Map(facturasVinculadas.map((f) => [f.id, f]));

    // Enriquecer con la autorización de exoneración aplicada (si corresponde),
    // para que el drawer pueda mostrar motivo + quién autorizó + porcentaje/monto autorizado.
    const autorizacionIds = interesesCobrados.map((i) => i.autorizacion_descuento_id).filter((v): v is string => !!v);
    const autorizaciones = autorizacionIds.length
      ? await this.prisma.autorizaciones_descuento.findMany({
          where: { id: { in: autorizacionIds } },
          select: {
            id: true,
            descuento_monto: true,
            descuento_porcentaje: true,
            motivo: true,
            notas_supervisor: true,
            estado: true,
            created_at: true,
            updated_at: true,
            aprobador: { select: { id: true, nombres: true } },
            solicitante: { select: { id: true, nombres: true } },
          },
        })
      : [];
    const autorizacionMap = new Map(autorizaciones.map((a) => [a.id, a]));

    const interesesEnriquecidos = interesesCobrados.map((i) => ({
      ...i,
      factura_emitida: i.factura_cab_id ? (facturaMap.get(i.factura_cab_id) ?? null) : null,
      autorizacion_exoneracion: i.autorizacion_descuento_id
        ? (autorizacionMap.get(i.autorizacion_descuento_id) ?? null)
        : null,
    }));

    // Interés manual cobrado por este recibo (cargado desde Cuentas por Cobrar).
    // No lleva comprobante fiscal por diseño: el drawer lo muestra aparte para
    // no confundirlo con una mora cuya factura de intereses falló.
    const aplicacionesManual = await this.prisma.cob_interes_manual_aplicacion.findMany({
      where: { recibo_cobro_id: id },
      select: {
        monto: true,
        interes: {
          select: {
            motivo: true,
            monto: true,
            factura_cuota: { select: { nro_cuota: true } },
          },
        },
      },
    });
    const interesManual = aplicacionesManual.map((a) => ({
      monto: Number(a.monto),
      motivo: a.interes?.motivo ?? null,
      monto_cargo: Number(a.interes?.monto ?? 0),
      nro_cuota: a.interes?.factura_cuota?.nro_cuota ?? null,
    }));
    const interesManualTotal = interesManual.reduce((acc, i) => acc + i.monto, 0);

    return {
      ...recibo,
      intereses_cobrados: interesesEnriquecidos,
      interes_manual: interesManual,
      interes_manual_total: interesManualTotal,
    };
  }

  async findByCliente(clienteId: string, empresaId: string) {
    const recibos = await this.prisma.recibos_cobro.findMany({
      where: {
        cliente_id: clienteId,
        empresa_id: empresaId,
      },
      orderBy: { fecha_emision: 'desc' },
      include: {
        moneda: true,
        recibo_cobro_detalle: {
          include: {
            factura_cuotas: true,
            medio_pago: true,
            moneda: true,
          },
        },
      },
    });

    return recibos.map((recibo) => {
      // Prioridad: 1) moneda del recibo, 2) moneda del primer detalle
      let monedaRecibo = recibo.moneda;
      if (!monedaRecibo && recibo.recibo_cobro_detalle?.length > 0) {
        const primerDetalle = recibo.recibo_cobro_detalle[0];
        monedaRecibo = primerDetalle.moneda || null;
      }

      return {
        ...recibo,
        moneda: monedaRecibo,
        fecha_emision: formatDateTimeResponse(recibo.fecha_emision),
      };
    });
  }

  async update(id: string, updateReciboCobroDto: UpdateReciboCobroDto, empresaId?: string, userId?: string) {
    const recibo = await this.prisma.recibos_cobro.findUnique({
      where: { id },
    });

    if (!recibo) {
      throw new NotFoundException(`Recibo de cobro con ID ${id} no encontrado`);
    }

    // Solo permitir anular recibos emitidos
    if (updateReciboCobroDto.estado === ReciboCobroEstado.ANULADO && recibo.estado !== ReciboCobroEstado.EMITIDO) {
      throw new BadRequestException('Solo se pueden anular recibos emitidos');
    }

    const updated = await this.prisma.recibos_cobro.update({
      where: { id },
      data: updateReciboCobroDto,
    });
    this.logger.log(`Recibo de cobro actualizado: ${recibo.numero_recibo} (${id})`, 'CobrosService');
    await this.auditService.log({
      empresa_id: empresaId ?? String(recibo.empresa_id),
      user_id: userId,
      action: 'UPDATE',
      entity_type: 'recibo_cobro',
      entity_id: id,
      descripcion: `Recibo de cobro actualizado: ${recibo.numero_recibo}`,
      old_value: { estado: recibo.estado, observacion: recibo.observacion },
      new_value: { estado: updated.estado, observacion: updated.observacion },
    });
    return updated;
  }

  async anular(id: string, observacion?: string, empresaId?: string, userId?: string) {
    const recibo = await this.prisma.recibos_cobro.findUnique({
      where: { id },
      include: {
        recibo_cobro_detalle: true,
      },
    });

    if (!recibo) {
      throw new NotFoundException(`Recibo de cobro con ID ${id} no encontrado`);
    }

    if (recibo.estado === ReciboCobroEstado.ANULADO) {
      throw new BadRequestException('El recibo ya está anulado');
    }

    // Revertir los pagos en una transacción
    const result = await this.prisma.$transaction(async (tx) => {
      // Los intereses manuales que este recibo saldó vuelven a estar pendientes.
      await this.interesManual.revertirDeRecibo(tx, empresaId ?? String(recibo.empresa_id), id);

      // Revertir saldos de las cuotas
      const cuotasRevertidas: Array<{ cuenta_id: string | null }> = [];
      for (const detalle of recibo.recibo_cobro_detalle) {
        const cuota = await tx.factura_cuotas.findUnique({
          where: { id: detalle.factura_cuota_id },
        });

        if (cuota) {
          const saldoActual = Number(cuota.saldo_pendiente || 0);
          const nuevoSaldo = saldoActual + Number(detalle.monto_pagado);

          await tx.factura_cuotas.update({
            where: { id: detalle.factura_cuota_id },
            data: {
              saldo_pendiente: nuevoSaldo,
              estado: 'pendiente',
            },
          });
          cuotasRevertidas.push({ cuenta_id: cuota.cuenta_id });
        }
      }

      // El saldo de la cuenta es la suma de sus cuotas, y las acabamos de mover.
      // Sin esto la cuenta se queda con el saldo que calculó el cobro y muestra
      // una deuda menor que la real mientras el recibo ya no existe.
      // No se filtra por estado a propósito: la cuenta que este cobro cerró como
      // 'pagado' es justamente la que hay que reabrir.
      for (const cuentaId of cuentasAfectadas(cuotasRevertidas)) {
        const cuotasCuenta = await tx.factura_cuotas.findMany({
          where: { cuenta_id: cuentaId },
          select: { saldo_pendiente: true, dmoncuota: true },
        });
        const { saldo, estado } = saldoDeCuenta(cuotasCuenta);
        await tx.cuentas_cobrar.update({
          where: { id: cuentaId },
          data: { saldo_pendiente: saldo, estado },
        });
      }

      // Revertir movimientos de caja (efectivo + tarjeta + transferencia)
      if (recibo.sesion_caja_id) {
        const medioIds = Array.from(
          new Set(recibo.recibo_cobro_detalle.map((d) => d.medio_pago_id).filter(Boolean) as string[]),
        );
        const medios = medioIds.length
          ? await tx.medio_pago.findMany({
              where: { id: { in: medioIds } },
              select: { id: true, codigo: true },
            })
          : [];
        const codigoPorMedio = new Map(medios.map((m) => [m.id, m.codigo]));

        let montoEfectivoAnular = 0;
        let montoTarjetaAnular = 0;
        let montoTransferenciaAnular = 0;
        for (const d of recibo.recibo_cobro_detalle) {
          const codigo = d.medio_pago_id ? codigoPorMedio.get(d.medio_pago_id) : null;
          const monto = Number(d.monto_pagado || 0);
          if (codigo === 1) montoEfectivoAnular += monto;
          else if (codigo === 3 || codigo === 4 || codigo === 8) montoTarjetaAnular += monto;
          else if (codigo === 5) montoTransferenciaAnular += monto;
        }

        if (montoEfectivoAnular > 0 || montoTarjetaAnular > 0 || montoTransferenciaAnular > 0) {
          const sesion = await tx.sesiones_caja.findUnique({
            where: { id: recibo.sesion_caja_id },
          });
          if (sesion) {
            const tipoMovSalida = await tx.tipo_movimiento_cajas.findFirst({
              where: { descripcion: { contains: 'Salida', mode: 'insensitive' } },
            });
            const totalAnular = montoEfectivoAnular + montoTarjetaAnular + montoTransferenciaAnular;
            if (totalAnular > 0) {
              await tx.movimiento_cajas.create({
                data: {
                  sesion_id: sesion.id,
                  empresa_id: empresaId ?? String(recibo.empresa_id),
                  tipo_movimiento_caja_id: tipoMovSalida?.id || null,
                  monto: totalAnular,
                  descripcion: `Anulación Recibo ${recibo.numero_recibo}${observacion ? ` | ${observacion}` : ''}`,
                  referencia: id,
                  usuario_id: userId,
                  fecha_mov: new Date().toISOString(),
                },
              });
            }
            // Resta atómica con piso en 0 (GREATEST) en una sola sentencia SQL:
            // Prisma no ofrece un `decrement` con clamp, y leer-restar-escribir
            // tiene la misma race condition que el fix de facturas.service.ts.
            await tx.$executeRaw`
              UPDATE sesiones_caja
              SET
                monto_entradas_manual = GREATEST(monto_entradas_manual - ${montoEfectivoAnular}, 0),
                monto_ventas_tarjeta = GREATEST(monto_ventas_tarjeta - ${montoTarjetaAnular}, 0),
                monto_ventas_transferencia = GREATEST(monto_ventas_transferencia - ${montoTransferenciaAnular}, 0),
                updated_at = NOW()
              WHERE id = ${sesion.id}::uuid`;
          }
        }
      }

      // Anular el recibo
      const anulado = await tx.recibos_cobro.update({
        where: { id },
        data: {
          estado: ReciboCobroEstado.ANULADO,
          observacion: observacion || recibo.observacion,
        },
      });
      return anulado;
    });

    // Fase 2: Revertir la reducción de saldo crédito del cliente.
    // Tiene que devolver EXACTAMENTE lo que restó el cobro, que es capital más
    // la mora/interés de la cabecera (ver `montoTotal` en create()). Devolver
    // solo el capital dejaba al cliente debiendo de menos para siempre: un ciclo
    // cobro→anulación con interés le perdonaba el interés en silencio.
    const montoTotalAnulado =
      recibo.recibo_cobro_detalle.reduce((sum, d) => sum + Number(d.monto_pagado), 0) +
      Number(recibo.mora_total ?? 0);
    if (montoTotalAnulado > 0) {
      try {
        await this.clientesService.actualizarSaldoPendiente(
          recibo.cliente_id,
          montoTotalAnulado,
          'sumar',
          empresaId ?? String(recibo.empresa_id),
          userId,
        );
      } catch (err) {
        this.logger.warn(
          `No se pudo revertir saldo crédito del cliente ${recibo.cliente_id} al anular recibo: ${err}`,
          'CobrosService',
        );
      }
    }

    this.logger.log(`Recibo de cobro anulado: ${recibo.numero_recibo} (${id})`, 'CobrosService');
    await this.auditService.log({
      empresa_id: empresaId ?? String(recibo.empresa_id),
      user_id: userId,
      action: 'DELETE',
      entity_type: 'recibo_cobro',
      entity_id: id,
      descripcion: `Recibo de cobro anulado: ${recibo.numero_recibo}`,
      old_value: { estado: recibo.estado, monto_total: Number(recibo.monto_total) },
    });

    // Reversión contable — fire and forget
    if (userId) {
      this.contabilidadIntegracion.revertirDocumento('recibos_cobro', id, userId).catch((err) => {
        this.logger.error?.(`Error revirtiendo contabilidad cobro ${id}: ${err.message}`);
      });
    }

    // Cancelar comisión de cobranza vinculada — fire and forget
    this.asignacionFacturas.cancelarComisionesPorRecibo(id, empresaId ?? String(recibo.empresa_id)).catch((err) => {
      this.logger.error?.(`Error cancelando comisiones de recibo ${id}: ${err.message}`);
    });

    return result;
  }

  // ==================== DISTRIBUCIÓN AUTOMÁTICA DE PAGOS ====================

  /**
   * Preview: calcula cómo se distribuiría un monto entre las cuotas seleccionadas.
   * Ordena por vencimiento (más antigua primero), aplica descuento si existe,
   * y distribuye el monto pagando cuotas completas o parciales.
   */
  async previewDistribucion(dto: PreviewDistribucionDto, empresaId?: string) {
    const { cuota_ids, monto_recibido, medio_pago_id, descuento_porcentaje, descuento_monto, motivo_descuento } = dto;

    if (!cuota_ids || cuota_ids.length === 0) {
      throw new BadRequestException('Debe seleccionar al menos una cuota');
    }
    if (monto_recibido <= 0) {
      throw new BadRequestException('El monto a pagar debe ser mayor a 0');
    }

    // Obtener cuotas con sus saldos, ordenadas por vencimiento
    const cuotas = await this.prisma.factura_cuotas.findMany({
      where: {
        id: { in: cuota_ids },
        estado: { notIn: ['pagado', 'Pagado'] },
      },
      include: {
        factura_cab: {
          select: {
            id: true,
            dnumdoc: true,
            dest: true,
            dpunexp: true,
            moneda: true,
            dticam: true,
          },
        },
      },
      orderBy: { dvenccuo: 'asc' },
    });

    if (cuotas.length === 0) {
      throw new BadRequestException('No se encontraron cuotas pendientes con los IDs proporcionados');
    }

    // Calcular total original de las cuotas seleccionadas
    const totalOriginal = cuotas.reduce((sum, c) => {
      return sum + Number(c.saldo_pendiente || c.dmoncuota);
    }, 0);

    // Cargar config de mora de la empresa para calcular mora por cuota vencida.
    const configMora = empresaId ? await this.prisma.config_mora.findFirst({ where: { empresa_id: empresaId } }) : null;
    // Cargar config de intereses para saber si IVA va por fuera (suma al cobro)
    // o incluido (no altera el monto cobrado, sólo el desglose de la factura).
    const configIntereses = empresaId
      ? await this.prisma.cob_config_intereses.findFirst({ where: { empresa_id: empresaId } })
      : null;
    const ivaModoPorFuera = (configIntereses as any)?.iva_modo === 'POR_FUERA';
    const ivaPctIntereses = Number(configIntereses?.iva_porcentaje ?? 0);
    const hoy = new Date();
    hoy.setHours(0, 0, 0, 0);

    const calcularMoraCuota = (cuota: (typeof cuotas)[number]): number => {
      if (!configMora?.activo) return 0;
      const venc = new Date(cuota.dvenccuo);
      venc.setHours(0, 0, 0, 0);
      const diasVencido = Math.floor((hoy.getTime() - venc.getTime()) / 86400000);
      if (diasVencido <= 0) return 0;
      const diasEfectivos = Math.max(0, diasVencido - (configMora.periodo_gracia ?? 0));
      if (diasEfectivos <= 0) return 0;

      // Base de cálculo: 'capital' (solo capital pendiente) usa saldo; 'cuota_completa' usa monto original de la cuota.
      const base =
        configMora.base_calculo === 'cuota_completa'
          ? Number(cuota.dmoncuota)
          : Number(cuota.saldo_pendiente || cuota.dmoncuota);

      const tasa = Number(configMora.tasa || 0);
      let mora = 0;
      if (configMora.tipo_calculo === 'diario') mora = base * (tasa / 100) * diasEfectivos;
      else if (configMora.tipo_calculo === 'mensual') mora = base * (tasa / 100) * (diasEfectivos / 30);
      else if (configMora.tipo_calculo === 'fijo_por_cuota') mora = tasa;
      const montoMin = Number((configMora as any).monto_minimo_mora || 0);
      const montoMax = Number((configMora as any).monto_maximo_mora || 0);
      if (montoMin && mora < montoMin) mora = montoMin;
      if (montoMax && mora > montoMax) mora = montoMax;
      let moraFinal = Math.round(mora);
      // Si la empresa configuró IVA por fuera para intereses moratorios, el
      // cliente paga mora + IVA. La mora calculada por config_mora es el monto
      // gravado; le sumamos el IVA para que el cobrador cobre el total correcto.
      if (ivaModoPorFuera && ivaPctIntereses > 0 && moraFinal > 0) {
        moraFinal += Math.round((moraFinal * ivaPctIntereses) / 100);
      }
      return moraFinal;
    };

    // Interés cargado a mano desde Cuentas por Cobrar: el de estas cuotas y el
    // de la cuenta a la que pertenecen. Va aparte de la mora calculada por tasa.
    const interesManual = empresaId
      ? await this.interesManual.pendienteParaCobro(
          empresaId,
          cuotas.map((c) => c.id),
          [...new Set(cuotas.map((c) => c.factura_cab.id))],
        )
      : { porCuota: new Map<string, number>(), porCuenta: new Map<string, number>(), total: 0 };

    // Calcular descuento
    let descuentoCalculado = 0;
    if (descuento_porcentaje && descuento_porcentaje > 0) {
      descuentoCalculado = Math.round(totalOriginal * (descuento_porcentaje / 100));
    } else if (descuento_monto && descuento_monto > 0) {
      descuentoCalculado = descuento_monto;
    }

    // Monto efectivo a aplicar = lo que paga + descuento
    const montoEfectivoAplicar = monto_recibido + descuentoCalculado;

    // Distribuir entre cuotas (más antigua primero)
    let montoDisponible = montoEfectivoAplicar;
    const distribucion = [];

    for (const cuota of cuotas) {
      if (montoDisponible <= 0) break;

      const saldoCuota = Number(cuota.saldo_pendiente || cuota.dmoncuota);

      // Calcular descuento proporcional para esta cuota
      let descuentoCuota = 0;
      if (descuentoCalculado > 0 && totalOriginal > 0) {
        descuentoCuota = Math.round(descuentoCalculado * (saldoCuota / totalOriginal));
      }

      // Monto que se aplica a esta cuota
      const montoAplicable = Math.min(montoDisponible, saldoCuota);
      // Lo que realmente paga el cliente (sin contar descuento)
      const montoPagado = Math.max(0, montoAplicable - descuentoCuota);
      const esParcial = montoAplicable < saldoCuota;
      const saldoRestante = Math.max(0, saldoCuota - montoAplicable);

      const moraCuota = calcularMoraCuota(cuota);

      distribucion.push({
        factura_cuota_id: cuota.id,
        nro_cuota: cuota.nro_cuota,
        fecha_vencimiento: formatDateResponse(cuota.dvenccuo, true),
        factura_numero: `${cuota.factura_cab.dest}-${cuota.factura_cab.dpunexp}-${cuota.factura_cab.dnumdoc}`,
        saldo_original: saldoCuota,
        descuento_aplicado: descuentoCuota,
        monto_pagado: montoPagado,
        monto_aplicado: montoAplicable,
        mora_calculada: moraCuota,
        interes_manual: interesManual.porCuota.get(cuota.id) ?? 0,
        es_pago_parcial: esParcial,
        saldo_restante: saldoRestante,
        estado_resultante: saldoRestante <= 0 ? 'pagado' : 'pendiente',
        medio_pago_id,
      });

      montoDisponible -= montoAplicable;
    }

    const totalPagado = distribucion.reduce((s, d) => s + d.monto_pagado, 0);
    const totalDescuento = distribucion.reduce((s, d) => s + d.descuento_aplicado, 0);
    const totalAplicado = distribucion.reduce((s, d) => s + d.monto_aplicado, 0);
    const totalMora = distribucion.reduce((s, d) => s + (d.mora_calculada || 0), 0);
    // El de la cuenta no cuelga de ninguna cuota: se suma aparte.
    const interesManualCuenta = [...interesManual.porCuenta.values()].reduce((a, b) => a + b, 0);
    const totalInteresManual =
      distribucion.reduce((s, d) => s + (d.interes_manual || 0), 0) + interesManualCuenta;
    // Capital primero: el interés sale de lo que el capital no consumió del
    // monto que el cliente entregó. Ver docs/superpowers/specs/2026-09-25-*.
    const imputacion = imputarCobro({
      montoRecibido: monto_recibido,
      totalPagadoCapital: totalPagado,
      interesPendiente: totalInteresManual,
    });

    return {
      resumen: {
        total_cuotas_seleccionadas: cuotas.length,
        total_original: totalOriginal,
        descuento_porcentaje: descuento_porcentaje || null,
        descuento_monto_total: totalDescuento,
        motivo_descuento: motivo_descuento || null,
        monto_recibido,
        total_pagado: totalPagado,
        total_aplicado: totalAplicado,
        total_mora: totalMora,
        // Sigue significando PENDIENTE: la app del cobrador lo lee así.
        total_interes_manual: totalInteresManual,
        interes_manual_cuenta: interesManualCuenta,
        interes_manual_pendiente: totalInteresManual,
        interes_manual_imputado: imputacion.interes,
        interes_manual_restante: imputacion.interesRestante,
        // Vuelto real: ya descontado el interés que este cobro se llevó.
        monto_sobrante: imputacion.vuelto,
        cuotas_pagadas_completas: distribucion.filter((d) => !d.es_pago_parcial).length,
        cuotas_pagadas_parcial: distribucion.filter((d) => d.es_pago_parcial).length,
        cuotas_sin_pago: cuotas.length - distribucion.length,
      },
      distribucion,
      moneda: cuotas[0]?.factura_cab?.moneda || null,
    };
  }

  /**
   * Crea un cobro usando distribución automática.
   * Recibe los mismos datos que previewDistribucion + datos de cabecera.
   */
  async createFromDistribucion(
    dto: PreviewDistribucionDto,
    sucursalId: string,
    clienteId: string,
    sesionCajaId: string | null,
    userId: string,
    empresaId: string,
    observacion?: string,
    cobrador_id?: string,
    fecha_registro?: string,
    numero_cheque?: string,
    banco_emisor?: string,
    // Id del banco en el catálogo. `banco_emisor` sigue guardando el texto
    // porque es lo que viaja a SIFEN (dBcoEmi); el id es para poder agrupar y
    // filtrar sin depender de cómo se escribió el nombre.
    banco_id?: string,
    fecha_emision?: string,
    // Fase A.3: exoneraciones de interés por cuota (factura_cuota_id -> {monto, autorizacion_id})
    exoneracionesIntereses?: Array<{
      factura_cuota_id: string;
      monto_exonerado: number;
      autorizacion_descuento_id?: string | null;
    }>,
    // Cobros desde la app del cobrador: nunca heredan la caja de otro.
    opciones?: { soloSesionPropia?: boolean },
  ) {
    // Generar el preview para obtener la distribución (incluye mora calculada por config_mora)
    const preview = await this.previewDistribucion(dto, empresaId);

    if (preview.distribucion.length === 0) {
      throw new BadRequestException('No hay cuotas a las que aplicar el pago');
    }

    // Fase A.3.7: si no vinieron exoneraciones explícitas, buscar autorizaciones
    // aprobadas y vigentes de tipo EXONERACION_INTERES para las cuotas del cobro.
    // El cobrador no las pide inline: las creó previamente el supervisor.
    const cuotaIdsConMora = preview.distribucion
      .filter((d) => (d.mora_calculada ?? 0) > 0)
      .map((d) => d.factura_cuota_id);
    const exoneracionesFinales = (exoneracionesIntereses ?? []).slice();
    // Marcar como usadas también las autorizaciones que llegaron explícitas desde el frontend:
    // antes solo se marcaban las descubiertas por auto-fetch, dejando las del flujo wizard como "Activo".
    let autorizacionesAplicadas: Array<{ id: string }> = exoneracionesFinales
      .map((e) => e.autorizacion_descuento_id)
      .filter((v): v is string => !!v)
      .map((id) => ({ id }));
    if (cuotaIdsConMora.length > 0) {
      const yaCubiertas = new Set(exoneracionesFinales.map((e) => e.factura_cuota_id));
      const cuotasPendientes = cuotaIdsConMora.filter((id) => !yaCubiertas.has(id));
      if (cuotasPendientes.length > 0) {
        const autorizs = await this.autorizaciones.getExoneracionesActivasPorCuotas(empresaId, cuotasPendientes);
        for (const a of autorizs) {
          if (!a.factura_cuota_id) continue;
          const detalle = preview.distribucion.find((d) => d.factura_cuota_id === a.factura_cuota_id);
          const moraCuota = Number(detalle?.mora_calculada ?? 0);
          if (moraCuota <= 0) continue;
          // Monto exonerado: si la autorización es por porcentaje, se aplica sobre la mora calculada;
          // si es por monto fijo, se topea con la mora actual (no se exonera más que la deuda real).
          const monto = a.descuento_porcentaje
            ? Math.round((moraCuota * Number(a.descuento_porcentaje)) / 100)
            : Math.min(Number(a.descuento_monto ?? 0), moraCuota);
          if (monto <= 0) continue;
          exoneracionesFinales.push({
            factura_cuota_id: a.factura_cuota_id,
            monto_exonerado: monto,
            autorizacion_descuento_id: a.id,
          });
          autorizacionesAplicadas.push({ id: a.id });
        }
      }
    }

    // Mora neta a cobrar: bruto menos exoneraciones aplicadas (explícitas + auto-cargadas).
    // El recibo persiste el monto NETO porque el cliente no paga lo exonerado.
    const totalMoraBruto = Number(preview.resumen.total_mora || 0);
    const totalExonerado = exoneracionesFinales.reduce((s, e) => s + Number(e.monto_exonerado || 0), 0);
    const moraNeta = Math.max(0, totalMoraBruto - totalExonerado);
    // El interés manual ya sale de adentro de lo que el cliente entregó: el
    // preview imputó capital primero y el resto al interés. Nadie lo digita.
    const totalInteresManual = Number((preview.resumen as any).interes_manual_imputado || 0);
    if (dto.interes_manual_monto != null) {
      this.logger.warn(
        `interes_manual_monto está deprecado y se ignora (llegó ${dto.interes_manual_monto}); ` +
          `el interés se imputa después del capital`,
        'CobrosService',
      );
    }
    // Invariante del recibo: mora_total = mora neta + interés manual cobrado.
    // RecibosPanel resta interes_manual_total para aislar la mora por tasa.
    const moraTotalNeta = moraNeta + totalInteresManual;

    // Convertir distribución a formato CreateReciboCobroDto
    const createDto: CreateReciboCobroDto = {
      sucursal_id: sucursalId,
      cliente_id: clienteId,
      sesion_caja_id: sesionCajaId,
      moneda_id: dto.moneda_id,
      cotizacion: dto.cotizacion,
      // dto.monto_recibido ya incluye el interés manual (el preview lo imputó
      // de adentro). Solo la mora por tasa se suma encima, porque esa sí va
      // aparte de lo que el cliente entregó para las cuotas.
      monto_recibido: dto.monto_recibido + moraNeta,
      descuento_porcentaje: dto.descuento_porcentaje,
      descuento_monto: preview.resumen.descuento_monto_total,
      motivo_descuento: dto.motivo_descuento,
      mora_total: moraTotalNeta,
      interes_manual_cobrado: totalInteresManual,
      observacion,
      cobrador_id,
      fecha_registro,
      fecha_emision,
      detalles: preview.distribucion.map((d) => ({
        factura_cuota_id: d.factura_cuota_id,
        medio_pago_id: d.medio_pago_id,
        monto_original: d.saldo_original,
        monto_pagado: d.monto_pagado,
        descuento_aplicado: d.descuento_aplicado,
        es_pago_parcial: d.es_pago_parcial,
        // Mora neta de esta cuota. El interés manual efectivamente cobrado lo
        // suma `create()`, que es quien sabe cómo se repartió.
        mora_monto: Math.max(
          0,
          Number(d.mora_calculada || 0) -
            Number(exoneracionesFinales.find((e) => e.factura_cuota_id === d.factura_cuota_id)?.monto_exonerado || 0),
        ),
        moneda_id: dto.moneda_id,
        cotizacion: dto.cotizacion,
        numero_cheque: numero_cheque || undefined,
        banco_emisor: banco_emisor || undefined,
        banco_id: banco_id || undefined,
      })),
    };

    const recibo = await this.create(createDto, userId, empresaId, opciones);

    // Fase A.3: persistir intereses moratorios cobrados por cuota
    if (recibo?.id) {
      await this.procesarInteresesMoratorios(
        recibo.id,
        (recibo as any).numero_recibo ?? null,
        empresaId,
        userId,
        preview.distribucion,
        exoneracionesFinales,
      );
      // Marcar las autorizaciones de exoneración como usadas
      for (const a of autorizacionesAplicadas) {
        try {
          await this.autorizaciones.marcarUsada(a.id, recibo.id);
        } catch (err) {
          this.logger.error(
            `No se pudo marcar autorización ${a.id} como usada para recibo ${recibo.id}: ${err}`,
            'CobrosService',
          );
        }
      }
      // Fase A.3.6: emisión real post-TX. El cobro core ya está confirmado;
      // si SET falla, las filas quedan con error_emision para reintento.
      try {
        const emi = await this.interesesMoratorios.emitirFacturaParaRecibo(empresaId, recibo.id, userId);
        if (emi.factura_cab_id) {
          await this.auditService.log({
            empresa_id: empresaId,
            user_id: userId,
            action: 'CREATE',
            entity_type: 'factura_intereses_moratorios',
            entity_id: emi.factura_cab_id,
            descripcion: `Factura electrónica de intereses moratorios emitida | Recibo: ${recibo.numero_recibo} | Líneas: ${emi.emitidos}`,
            new_value: {
              recibo_id: recibo.id,
              factura_cab_id: emi.factura_cab_id,
              lineas_emitidas: emi.emitidos,
            },
          });
        } else if (emi.error) {
          await this.auditService.log({
            empresa_id: empresaId,
            user_id: userId,
            action: 'ERROR',
            entity_type: 'factura_intereses_moratorios',
            entity_id: recibo.id,
            descripcion: `Falló emisión de factura de intereses moratorios | Recibo: ${recibo.numero_recibo} | ${emi.error}`,
            new_value: { recibo_id: recibo.id, error: emi.error, saltados: emi.saltados },
          });
        } else if (emi.saltados > 0) {
          await this.auditService.log({
            empresa_id: empresaId,
            user_id: userId,
            action: 'SKIP',
            entity_type: 'factura_intereses_moratorios',
            entity_id: recibo.id,
            descripcion: `Emisión saltada | Recibo: ${recibo.numero_recibo} | tipo_comprobante != FACTURA o pendientes ya emitidos (saltados=${emi.saltados})`,
            new_value: { recibo_id: recibo.id, saltados: emi.saltados },
          });
        }
      } catch (err) {
        const msg = err instanceof Error ? err.message : String(err);
        this.logger.error(`Falló emisión de factura de intereses para recibo ${recibo.id}: ${msg}`, 'CobrosService');
        await this.auditService.log({
          empresa_id: empresaId,
          user_id: userId,
          action: 'ERROR',
          entity_type: 'factura_intereses_moratorios',
          entity_id: recibo.id,
          descripcion: `Excepción al emitir factura de intereses moratorios | Recibo: ${recibo.numero_recibo} | ${msg}`,
          new_value: { recibo_id: recibo.id, error: msg },
        });
      }
    }

    return recibo;
  }

  /**
   * Fase A.3: Registra una fila por cuota con mora cobrada en cob_interes_cobrado.
   * No emite factura/ND fiscal todavía: el factura_cab_id queda null y la emisión
   * se hace en chunk siguiente integrando facturas.service.
   */
  private async procesarInteresesMoratorios(
    reciboId: string,
    numeroRecibo: string | null,
    empresaId: string,
    userId: string,
    distribucion: Array<{ factura_cuota_id: string; mora_calculada?: number }>,
    exoneraciones: Array<{
      factura_cuota_id: string;
      monto_exonerado: number;
      autorizacion_descuento_id?: string | null;
    }>,
  ) {
    const reciboLabel = numeroRecibo || reciboId;
    // Resolver nombres legibles de cuotas (factura + nro_cuota) para la auditoría.
    const cuotaIdsAll = distribucion.map((d) => d.factura_cuota_id);
    const cuotasInfo = cuotaIdsAll.length
      ? await this.prisma.factura_cuotas.findMany({
          where: { id: { in: cuotaIdsAll } },
          select: {
            id: true,
            nro_cuota: true,
            factura_cab: { select: { dest: true, dpunexp: true, dnumdoc: true } },
          },
        })
      : [];
    const cuotaLabel = (id: string) => {
      const c = cuotasInfo.find((x) => x.id === id);
      if (!c) return id;
      const fac = c.factura_cab;
      const facStr = fac ? `${fac.dest}-${fac.dpunexp}-${fac.dnumdoc}` : '-';
      return `${facStr} cuota ${c.nro_cuota ?? '-'}`;
    };

    const conMora = distribucion.filter((d) => (d.mora_calculada || 0) > 0);
    if (conMora.length === 0) {
      // Diagnóstico: ¿por qué ninguna cuota generó mora? Las causas habituales
      // son config_mora.activo=false, tasa=0, periodo_gracia que cubre el atraso
      // o cuotas dentro del período de gracia. Lo dejamos auditado para soporte.
      const cuotaIds = distribucion.map((d) => d.factura_cuota_id);
      const [configMora, cuotas] = await Promise.all([
        this.prisma.config_mora.findFirst({
          where: { empresa_id: empresaId },
          select: {
            activo: true,
            tasa: true,
            tipo_calculo: true,
            base_calculo: true,
            periodo_gracia: true,
          },
        }),
        this.prisma.factura_cuotas.findMany({
          where: { id: { in: cuotaIds } },
          select: { id: true, dvenccuo: true },
        }),
      ]);
      const hoy = new Date();
      hoy.setHours(0, 0, 0, 0);
      const diasMaxAtraso = cuotas.reduce((max, c) => {
        const v = new Date(c.dvenccuo);
        v.setHours(0, 0, 0, 0);
        const d = Math.floor((hoy.getTime() - v.getTime()) / 86400000);
        return d > max ? d : max;
      }, 0);
      let motivo = 'Ninguna cuota generó mora';
      if (!configMora) motivo = 'No existe config_mora para la empresa';
      else if (!configMora.activo) motivo = 'config_mora.activo = false (cálculo de mora deshabilitado)';
      else if (Number(configMora.tasa ?? 0) <= 0) motivo = 'config_mora.tasa = 0';
      else if (diasMaxAtraso <= (configMora.periodo_gracia ?? 0))
        motivo = `Cuotas dentro del período de gracia (max atraso=${diasMaxAtraso}d ≤ gracia=${configMora.periodo_gracia}d)`;
      await this.auditService.log({
        empresa_id: empresaId,
        user_id: userId,
        action: 'SKIP',
        entity_type: 'cob_interes_cobrado',
        entity_id: reciboId,
        descripcion: `Intereses moratorios NO registrados | Recibo ${reciboLabel} | Cuotas: ${cuotaIdsAll.map(cuotaLabel).join(', ') || '—'} | Motivo: ${motivo}`,
        new_value: {
          recibo_id: reciboId,
          recibo_numero: numeroRecibo,
          motivo,
          dias_max_atraso: diasMaxAtraso,
          cuotas: cuotaIdsAll.map((id) => ({ factura_cuota_id: id, descripcion: cuotaLabel(id) })),
          config_mora: configMora
            ? {
                activo: configMora.activo,
                tasa: Number(configMora.tasa ?? 0),
                periodo_gracia: configMora.periodo_gracia,
                tipo_calculo: configMora.tipo_calculo,
                base_calculo: configMora.base_calculo,
              }
            : null,
        },
      });
      return;
    }

    const exoMap = new Map(exoneraciones.map((e) => [e.factura_cuota_id, e]));
    const registrados: Array<{ factura_cuota_id: string; total: number }> = [];
    const pausados: Array<{ factura_cuota_id: string; motivo?: string }> = [];

    try {
      await this.prisma.$transaction(async (tx) => {
        for (const d of conMora) {
          const exo = exoMap.get(d.factura_cuota_id);
          const calc = await this.interesesMoratorios.previewParaCuota(
            empresaId,
            d.factura_cuota_id,
            exo?.monto_exonerado ?? 0,
          );
          // Si la política pausa la mora para este cliente, no se registra cobro.
          if (calc.pausado) {
            pausados.push({ factura_cuota_id: d.factura_cuota_id, motivo: calc.motivo_pausa });
            continue;
          }
          // Cuando neto = 0 pero hubo exoneración registramos igual para auditar
          // la exoneración aplicada (monto_interes_cobrado=0, monto_exonerado>0).
          // Solo saltamos si no hay mora alguna (bruto = 0).
          if (calc.monto_interes_neto <= 0 && calc.monto_exonerado <= 0) {
            continue;
          }

          await this.interesesMoratorios.registrarCobro(
            tx,
            empresaId,
            userId,
            calc,
            {
              recibo_cobro_id: reciboId,
              autorizacion_descuento_id: exo?.autorizacion_descuento_id ?? null,
            },
            null, // factura_cab_id se setea en emitirFacturaParaRecibo (post-TX)
          );
          registrados.push({
            factura_cuota_id: d.factura_cuota_id,
            total: calc.total_a_cobrar,
          });
        }
      });
      if (registrados.length > 0) {
        const totalIntereses = registrados.reduce((s, r) => s + r.total, 0);
        await this.auditService.log({
          empresa_id: empresaId,
          user_id: userId,
          action: 'CREATE',
          entity_type: 'cob_interes_cobrado',
          entity_id: reciboId,
          descripcion: `Intereses moratorios registrados | Recibo ${reciboLabel} | Cuotas: ${registrados.map((r) => cuotaLabel(r.factura_cuota_id)).join(', ')} | Total: ${totalIntereses}`,
          new_value: {
            recibo_id: reciboId,
            recibo_numero: numeroRecibo,
            cuotas_con_interes: registrados.length,
            cuotas_pausadas: pausados.length,
            total_intereses: totalIntereses,
            detalle: registrados.map((r) => ({
              factura_cuota_id: r.factura_cuota_id,
              descripcion: cuotaLabel(r.factura_cuota_id),
              total: r.total,
            })),
            pausados: pausados.map((p) => ({
              factura_cuota_id: p.factura_cuota_id,
              descripcion: cuotaLabel(p.factura_cuota_id),
              motivo: p.motivo,
            })),
          },
        });
      } else if (pausados.length > 0) {
        await this.auditService.log({
          empresa_id: empresaId,
          user_id: userId,
          action: 'SKIP',
          entity_type: 'cob_interes_cobrado',
          entity_id: reciboId,
          descripcion: `Intereses moratorios pausados por política | Recibo ${reciboLabel} | Cuotas pausadas: ${pausados.map((p) => cuotaLabel(p.factura_cuota_id)).join(', ')}`,
          new_value: {
            recibo_id: reciboId,
            recibo_numero: numeroRecibo,
            pausados: pausados.map((p) => ({
              factura_cuota_id: p.factura_cuota_id,
              descripcion: cuotaLabel(p.factura_cuota_id),
              motivo: p.motivo,
            })),
          },
        });
      }
    } catch (err) {
      const msg = err instanceof Error ? err.message : String(err);
      this.logger.error(
        `No se pudieron registrar intereses moratorios del recibo ${reciboId}: ${msg}`,
        'CobrosService',
      );
      await this.auditService.log({
        empresa_id: empresaId,
        user_id: userId,
        action: 'ERROR',
        entity_type: 'cob_interes_cobrado',
        entity_id: reciboId,
        descripcion: `Excepción al registrar intereses moratorios | Recibo ${reciboLabel} | ${msg}`,
        new_value: { recibo_id: reciboId, recibo_numero: numeroRecibo, error: msg },
      });
    }
  }

  async getFacturasCreditoPendientes(clienteId: string, empresaId: string) {
    // Obtener facturas con cuotas pendientes: crédito normal + contado con cobro
    // diferido (cobradas en ruta), ambos modelan saldo vía factura_cuotas.
    const facturas = await this.prisma.factura_cab.findMany({
      where: {
        cliente_id: clienteId,
        empresa_id: empresaId,
        estado: { notIn: ESTADOS_FACTURA_NO_COBRABLE_PRISMA },
        OR: [
          { condicion_operacion: { codigo: 2 } }, // Crédito
          { cobro_diferido: true }, // Contado cobrado en ruta
        ],
        factura_cuotas: {
          some: {
            estado: { notIn: ['pagado', 'Pagado'] },
          },
        },
      },
      include: {
        moneda: true,
        factura_cuotas: true,
        factura_det: true,
        cuentas_cobrar: {
          select: {
            intervalo_dias: true,
            dia_fijo_pago_1: true,
            dia_fijo_pago_2: true,
            dia_cobro_semana: true,
            dias_gracia_override: true,
          },
        },
      },
      orderBy: { dfeemide: 'desc' },
    });

    // Calcular saldo pendiente y cuotas pagadas por factura
    return facturas.map((factura) => {
      const cuotasPendientes = factura.factura_cuotas.filter((c) => c.estado !== 'pagado' && c.estado !== 'Pagado');
      const cuotasPagadas = factura.factura_cuotas.filter((c) => c.estado === 'pagado' || c.estado === 'Pagado');
      const cuentaPolitica = factura.cuentas_cobrar?.[0];
      const politica_cobranza = cuentaPolitica
        ? {
            intervalo_dias: cuentaPolitica.intervalo_dias,
            dia_fijo_pago_1: cuentaPolitica.dia_fijo_pago_1,
            dia_fijo_pago_2: cuentaPolitica.dia_fijo_pago_2,
            dia_cobro_semana: cuentaPolitica.dia_cobro_semana,
            dias_gracia_override: cuentaPolitica.dias_gracia_override,
          }
        : null;

      // Calcular saldo pendiente total
      const saldoPendiente = cuotasPendientes.reduce((sum, c) => {
        const saldo =
          c.saldo_pendiente && Number(c.saldo_pendiente) > 0 ? Number(c.saldo_pendiente) : Number(c.dmoncuota);
        return sum + saldo;
      }, 0);

      // Calcular total de la factura (suma de todas las cuotas)
      const totalFactura = factura.factura_cuotas.reduce((sum, c) => sum + Number(c.dmoncuota), 0);

      return {
        id: factura.id,
        numero_factura: `${factura.dest}-${factura.dpunexp}-${factura.dnumdoc}`,
        dest: factura.dest,
        dpunexp: factura.dpunexp,
        dnumdoc: factura.dnumdoc,
        fecha_emision: formatDateResponse(factura.dfeemide, true),
        moneda: factura.moneda,
        dticam: factura.dticam, // Cotización
        total_factura: totalFactura,
        saldo_pendiente: saldoPendiente,
        cuotas_total: factura.factura_cuotas.length,
        cuotas_pagadas: cuotasPagadas.length,
        cuotas_pendientes: cuotasPendientes.length,
        cobrador_id: factura.cobrador_id ?? null,
        items: factura.factura_det,
        politica_cobranza,
      };
    });
  }

  async getCuotasPorFactura(facturaId: string, empresaId: string) {
    // Verificar que la factura pertenece a la empresa
    const factura = await this.prisma.factura_cab.findFirst({
      where: {
        id: facturaId,
        empresa_id: empresaId,
      },
      include: {
        moneda: true,
      },
    });

    if (!factura) {
      throw new NotFoundException('Factura no encontrada');
    }

    // Obtener cuotas pendientes de la factura
    const cuotas = await this.prisma.factura_cuotas.findMany({
      where: {
        factura_cab_id: facturaId,
        estado: { notIn: ['pagado', 'Pagado'] },
      },
      orderBy: { nro_cuota: 'asc' },
    });

    return cuotas.map((cuota) => ({
      ...cuota,
      saldo_pendiente:
        cuota.saldo_pendiente && Number(cuota.saldo_pendiente) > 0 ? cuota.saldo_pendiente : cuota.dmoncuota,
      dvenccuo: formatDateResponse(cuota.dvenccuo, true),
      moneda: factura.moneda,
      dticam: factura.dticam, // Cotización para convertir a Gs.
    }));
  }

  async getCuotasPendientes(clienteId: string, empresaId: string) {
    // Buscar cuotas pendientes - considerando que saldo_pendiente puede ser 0 o null
    // cuando la cuota nunca fue pagada parcialmente (usar dmoncuota en ese caso)
    const cuotas = await this.prisma.factura_cuotas.findMany({
      where: {
        factura_cab: {
          cliente_id: clienteId,
          empresa_id: empresaId,
          estado: { notIn: ESTADOS_FACTURA_NO_COBRABLE_PRISMA },
        },
        estado: { notIn: ['pagado', 'Pagado'] },
      },
      include: {
        factura_cab: {
          select: {
            id: true,
            dnumdoc: true,
            dest: true,
            dpunexp: true,
            dfeemide: true,
            dticam: true,
            total_factura: true,
            total_notas_credito: true,
            moneda: true,
            factura_det: {
              select: {
                id: true,
                item: true,
                ddesproser: true,
                cunimed: true,
                dcantproser: true,
                duniproser: true,
                dtotopeitem: true,
              },
              orderBy: { item: 'asc' },
            },
          },
        },
        // Política de cobranza acordada (heredada al facturar desde solicitud_credito).
        // Se expone al mobile para que el cobrador vea "Cobrar los días 5 y 20" en su app.
        cuentas_cobrar: {
          select: {
            intervalo_dias: true,
            dia_fijo_pago_1: true,
            dia_fijo_pago_2: true,
            dia_cobro_semana: true,
            dias_gracia_override: true,
          },
        },
      },
      orderBy: { dvenccuo: 'asc' },
    });

    // Promesas de pago vigentes: por cuota o generales a la factura.
    const facturaIds = Array.from(new Set(cuotas.map((c) => c.factura_cab_id)));
    const cuotaIds = cuotas.map((c) => c.id);
    const promesas =
      facturaIds.length === 0
        ? []
        : await this.prisma.promesas_pago.findMany({
            where: {
              empresa_id: empresaId,
              estado: 'pendiente',
              OR: [{ cuota_id: { in: cuotaIds } }, { factura_cab_id: { in: facturaIds }, cuota_id: null }],
            },
            orderBy: { fecha_prometida: 'asc' },
            select: {
              id: true,
              factura_cab_id: true,
              cuota_id: true,
              fecha_prometida: true,
              monto_prometido: true,
              notas: true,
              estado: true,
            },
          });
    const promesaPorCuota = new Map<string, (typeof promesas)[number]>();
    const promesaPorFactura = new Map<string, (typeof promesas)[number]>();
    for (const p of promesas) {
      if (p.cuota_id) {
        if (!promesaPorCuota.has(p.cuota_id)) promesaPorCuota.set(p.cuota_id, p);
      } else if (!promesaPorFactura.has(p.factura_cab_id)) {
        promesaPorFactura.set(p.factura_cab_id, p);
      }
    }

    const hoy = new Date();
    hoy.setHours(0, 0, 0, 0);

    // Ajuste por Notas de Crédito: igual que en getCuentasCobrarResumen, la
    // aplicación de una NC actualiza el saldo de la factura pero NO el
    // `saldo_pendiente` de cada cuota — sin este ajuste, una cuota ya cubierta
    // por NC sigue apareciendo acá como pendiente/vencida aunque el resumen
    // agregado del cliente (getCuentasCobrarPorCliente) ya la haya descontado.
    const saldoAjustadoPorCuota = this.distribuirNcEnCuotas(
      cuotas.map((c) => ({
        cuotaId: c.id,
        facturaId: c.factura_cab_id,
        saldoBruto:
          c.saldo_pendiente && Number(c.saldo_pendiente) > 0 ? Number(c.saldo_pendiente) : Number(c.dmoncuota),
        totalNc: Number((c.factura_cab as { total_notas_credito?: unknown })?.total_notas_credito) || 0,
      })),
    );

    // Filtrar cuotas que realmente tienen saldo pendiente tras el ajuste por NC.
    return cuotas
      .map((cuota) => {
        const venc = new Date(cuota.dvenccuo);
        venc.setHours(0, 0, 0, 0);
        const dias_vencido = Math.floor((hoy.getTime() - venc.getTime()) / 86_400_000);
        return {
          ...cuota,
          saldo_pendiente: saldoAjustadoPorCuota.get(cuota.id) ?? 0,
          dvenccuo: formatDateResponse(cuota.dvenccuo, true),
          dias_vencido,
          promesa_pago: promesaPorCuota.get(cuota.id) ?? promesaPorFactura.get(cuota.factura_cab_id) ?? null,
        };
      })
      .filter((cuota) => Number(cuota.saldo_pendiente) > 0);
  }

  /**
   * Verifica si existe una numeración activa para Recibo de pago (tipo_documento codigo=100)
   * para la sucursal indicada. Retorna info de la numeración o null si no existe.
   */
  async verificarNumeracionRecibo(empresaId: string, sucursalId: string) {
    const numeracion = await this.prisma.numeraciones_documento.findFirst({
      where: {
        empresa_id: empresaId,
        active: true,
        tipo_documento: {
          codigo: 100,
        },
        punto_expedicion: {
          sucursal_id: sucursalId,
        },
      },
      include: {
        tipo_documento: { select: { descripcion: true, codigo: true } },
        punto_expedicion: {
          select: {
            punto_expedicion: true,
            empresas_sucursales: {
              select: { punto_establecimiento: true, descripcion: true },
            },
          },
        },
      },
    });

    if (!numeracion) {
      return { existe: false, numeracion: null };
    }

    const dest = numeracion.punto_expedicion?.empresas_sucursales?.punto_establecimiento || '001';
    const dpunexp = numeracion.punto_expedicion?.punto_expedicion || '001';
    const siguienteNumero = `${dest}-${dpunexp}-${String(numeracion.numero_actual).padStart(7, '0')}`;

    return {
      existe: true,
      numeracion: {
        id: numeracion.id,
        siguiente_numero: siguienteNumero,
        numero_actual: numeracion.numero_actual,
        numero_final: numeracion.numero_final,
        sucursal: numeracion.punto_expedicion?.empresas_sucursales?.descripcion,
        tipo_documento: numeracion.tipo_documento?.descripcion,
      },
    };
  }

  // ==================== GESTIÓN DE CUENTAS A COBRAR ====================

  async getCuentasCobrarResumen(empresaId: string) {
    const hoy = new Date();
    hoy.setHours(0, 0, 0, 0);

    // Obtener todas las cuotas pendientes con información de factura y cliente.
    // Se incluye `total_notas_credito` para descontar las NC ya aplicadas: la
    // aplicación de NC actualiza el saldo de la factura pero NO ajusta el
    // `saldo_pendiente` de cada cuota, por lo que hay que compensarlo acá.
    const cuotasPendientes = await this.prisma.factura_cuotas.findMany({
      where: {
        estado: { notIn: ['pagado', 'Pagado'] },
        factura_cab: {
          empresa_id: empresaId,
          estado: { notIn: ESTADOS_FACTURA_NO_COBRABLE_PRISMA },
        },
      },
      select: {
        id: true,
        nro_cuota: true,
        dmoncuota: true,
        saldo_pendiente: true,
        dvenccuo: true,
        cmonecuo: true,
        factura_cab: {
          select: {
            id: true,
            dest: true,
            dpunexp: true,
            dnumdoc: true,
            dfeemide: true,
            total_notas_credito: true,
            moneda: { select: { id: true, codigo: true, simbolo: true, descripcion: true } },
            clientes: {
              select: {
                id: true,
                cod_cliente: true,
                personas: { select: { razon_social: true, ruc: true, dv: true, nro_documento: true, celular: true } },
              },
            },
          },
        },
      },
      orderBy: { dvenccuo: 'asc' },
    });

    // Distribuir la NC acumulada de cada factura entre sus cuotas, priorizando
    // las más viejas (aging real). Cuotas cuyo saldo queda en 0 se filtran de
    // la lista y no contabilizan totales.
    const saldoAjustadoPorCuota = this.distribuirNcEnCuotas(
      cuotasPendientes.map((c) => ({
        cuotaId: c.id,
        facturaId: c.factura_cab.id,
        saldoBruto: Number(c.saldo_pendiente) || Number(c.dmoncuota),
        totalNc: Number((c.factura_cab as any).total_notas_credito) || 0,
      })),
    );

    // Calcular totales
    let totalPendiente = 0;
    let totalVencido = 0;
    let totalPorVencer = 0;
    let cuotasVencidas = 0;
    let cuotasPorVencer = 0;

    const cuotasConDetalle = cuotasPendientes
      .map((cuota) => {
        const saldo = saldoAjustadoPorCuota.get(cuota.id) ?? 0;
        if (saldo <= 0) return null;
        const fechaVencimiento = new Date(cuota.dvenccuo);
        const diasVencido = Math.floor((hoy.getTime() - fechaVencimiento.getTime()) / (1000 * 60 * 60 * 24));
        const vencida = diasVencido > 0;

        totalPendiente += saldo;
        if (vencida) {
          totalVencido += saldo;
          cuotasVencidas++;
        } else {
          totalPorVencer += saldo;
          cuotasPorVencer++;
        }

        return {
          id: cuota.id,
          nro_cuota: cuota.nro_cuota,
          monto_cuota: Number(cuota.dmoncuota),
          saldo_pendiente: saldo,
          fecha_vencimiento: formatDateResponse(cuota.dvenccuo, true),
          dias_vencido: vencida ? diasVencido : 0,
          estado: vencida ? 'vencida' : 'pendiente',
          moneda: cuota.cmonecuo,
          factura: {
            id: cuota.factura_cab.id,
            numero: `${cuota.factura_cab.dest}-${cuota.factura_cab.dpunexp}-${cuota.factura_cab.dnumdoc}`,
            fecha_emision: formatDateResponse(cuota.factura_cab.dfeemide, true),
          },
          cliente: {
            id: cuota.factura_cab.clientes?.id,
            cod_cliente: cuota.factura_cab.clientes?.cod_cliente,
            razon_social: cuota.factura_cab.clientes?.personas?.razon_social,
            ruc: cuota.factura_cab.clientes?.personas?.ruc,
            dv: cuota.factura_cab.clientes?.personas?.dv,
            nro_documento: cuota.factura_cab.clientes?.personas?.nro_documento,
            celular: cuota.factura_cab.clientes?.personas?.celular,
          },
        };
      })
      .filter((row): row is NonNullable<typeof row> => row !== null);

    return {
      resumen: {
        total_pendiente: totalPendiente,
        total_vencido: totalVencido,
        total_por_vencer: totalPorVencer,
        cuotas_vencidas: cuotasVencidas,
        cuotas_por_vencer: cuotasPorVencer,
        cuotas_total: cuotasConDetalle.length,
      },
      cuotas: cuotasConDetalle,
    };
  }

  /**
   * Prorratea la NC acumulada de cada factura sobre sus cuotas, más viejas
   * primero. Devuelve un mapa `cuotaId → saldo_ajustado` (>= 0).
   *
   * Uso: cuando un reporte agrega cuotas y `factura_cab.total_notas_credito`
   * no está reflejado en `factura_cuotas.saldo_pendiente`. Las cuotas cuyo
   * saldo ajustado queda en 0 son las que "fueron pagadas" por la NC y
   * deben desaparecer del reporte.
   *
   * Nota: se asume que la lista de cuotas ya viene ordenada por vencimiento
   * ascendente (comportamiento estándar en los reportes de aging).
   */
  private distribuirNcEnCuotas(
    cuotas: Array<{ cuotaId: string; facturaId: string; saldoBruto: number; totalNc: number }>,
  ): Map<string, number> {
    const ncRestantePorFactura = new Map<string, number>();
    for (const c of cuotas) {
      if (!ncRestantePorFactura.has(c.facturaId)) {
        ncRestantePorFactura.set(c.facturaId, c.totalNc);
      }
    }
    const saldos = new Map<string, number>();
    for (const c of cuotas) {
      const ncRestante = ncRestantePorFactura.get(c.facturaId) ?? 0;
      const aplicado = Math.min(ncRestante, c.saldoBruto);
      saldos.set(c.cuotaId, c.saldoBruto - aplicado);
      ncRestantePorFactura.set(c.facturaId, ncRestante - aplicado);
    }
    return saldos;
  }

  /**
   * Dashboard de cuentas a cobrar con aging buckets y semáforos.
   * Optimizado para widget de dashboard — retorna resumen compacto.
   */
  async getCuentasCobrarDashboard(empresaId: string, usuarioId?: string) {
    const hoy = new Date();
    hoy.setHours(0, 0, 0, 0);

    // Mismo alcance por sucursal (y rubro del selector) que los listados: las
    // facturas por `dest`, los recibos por `sucursal_id`. Sin esto la tarjeta
    // del dashboard mostraba la cartera de toda la empresa.
    const alcance = await alcanceSucursalUsuario(this.prisma, usuarioId, empresaId);
    const filtroFacturas = await filtroFiscalPorSucursal(this.prisma, alcance);
    const filtroRecibos = filtroSucursalOperativo(alcance);

    const cuotasPendientes = await this.prisma.factura_cuotas.findMany({
      where: {
        estado: { notIn: ['pagado', 'Pagado'] },
        factura_cab: {
          empresa_id: empresaId,
          estado: { notIn: ESTADOS_FACTURA_NO_COBRABLE_PRISMA },
          ...(filtroFacturas ?? {}),
        },
      },
      include: {
        factura_cab: {
          include: {
            clientes: { include: { personas: true } },
          },
        },
      },
      orderBy: { dvenccuo: 'asc' },
    });

    // NC ya aplicadas se reflejan en `factura_cab.total_notas_credito` pero no
    // en `factura_cuotas.saldo_pendiente`; se prorratea sobre las cuotas más
    // viejas y se descartan las que quedan en 0.
    const saldoAjustadoPorCuota = this.distribuirNcEnCuotas(
      cuotasPendientes.map((c) => ({
        cuotaId: c.id,
        facturaId: c.factura_cab.id,
        saldoBruto: Number(c.saldo_pendiente) || Number(c.dmoncuota),
        totalNc: Number((c.factura_cab as any).total_notas_credito) || 0,
      })),
    );

    // Aging buckets
    const buckets = {
      al_dia: { monto: 0, cantidad: 0 },
      de_1_a_30: { monto: 0, cantidad: 0 },
      de_31_a_60: { monto: 0, cantidad: 0 },
      de_61_a_90: { monto: 0, cantidad: 0 },
      mas_de_90: { monto: 0, cantidad: 0 },
    };

    let totalPendiente = 0;
    let totalVencido = 0;
    let cuotasEfectivas = 0;
    const clientesMorosos = new Set<string>();
    const proximosVencimientos: Array<{
      cliente_razon_social: string;
      factura_numero: string;
      monto: number;
      saldo: number;
      fecha_vencimiento: string;
      dias_para_vencer: number;
    }> = [];

    const sieteDiasDespues = new Date(hoy);
    sieteDiasDespues.setDate(sieteDiasDespues.getDate() + 7);

    for (const cuota of cuotasPendientes) {
      const saldo = saldoAjustadoPorCuota.get(cuota.id) ?? 0;
      if (saldo <= 0) continue;
      cuotasEfectivas++;
      const fechaVencimiento = new Date(cuota.dvenccuo);
      const diasVencido = Math.floor((hoy.getTime() - fechaVencimiento.getTime()) / (1000 * 60 * 60 * 24));

      totalPendiente += saldo;

      if (diasVencido <= 0) {
        buckets.al_dia.monto += saldo;
        buckets.al_dia.cantidad++;

        // Próximos vencimientos (dentro de 7 días)
        if (fechaVencimiento <= sieteDiasDespues && proximosVencimientos.length < 5) {
          const diasParaVencer = Math.abs(diasVencido);
          proximosVencimientos.push({
            cliente_razon_social: cuota.factura_cab.clientes?.personas?.razon_social || 'Sin nombre',
            factura_numero: `${cuota.factura_cab.dest}-${cuota.factura_cab.dpunexp}-${cuota.factura_cab.dnumdoc}`,
            monto: Number(cuota.dmoncuota),
            saldo,
            fecha_vencimiento: formatDateResponse(cuota.dvenccuo, true),
            dias_para_vencer: diasParaVencer,
          });
        }
      } else if (diasVencido <= 30) {
        buckets.de_1_a_30.monto += saldo;
        buckets.de_1_a_30.cantidad++;
        totalVencido += saldo;
        if (cuota.factura_cab.clientes?.id) clientesMorosos.add(cuota.factura_cab.clientes.id);
      } else if (diasVencido <= 60) {
        buckets.de_31_a_60.monto += saldo;
        buckets.de_31_a_60.cantidad++;
        totalVencido += saldo;
        if (cuota.factura_cab.clientes?.id) clientesMorosos.add(cuota.factura_cab.clientes.id);
      } else if (diasVencido <= 90) {
        buckets.de_61_a_90.monto += saldo;
        buckets.de_61_a_90.cantidad++;
        totalVencido += saldo;
        if (cuota.factura_cab.clientes?.id) clientesMorosos.add(cuota.factura_cab.clientes.id);
      } else {
        buckets.mas_de_90.monto += saldo;
        buckets.mas_de_90.cantidad++;
        totalVencido += saldo;
        if (cuota.factura_cab.clientes?.id) clientesMorosos.add(cuota.factura_cab.clientes.id);
      }
    }

    // Total cobrado histórico (recibos no anulados)
    const totalCobradoAgg = await this.prisma.recibos_cobro.aggregate({
      where: {
        empresa_id: empresaId,
        estado: { not: ReciboCobroEstado.ANULADO },
        ...(filtroRecibos !== undefined ? { sucursal_id: filtroRecibos } : {}),
      },
      _sum: { monto_total: true },
    });

    return {
      total_pendiente: totalPendiente,
      total_vencido: totalVencido,
      total_al_dia: totalPendiente - totalVencido,
      cuotas_total: cuotasEfectivas,
      clientes_morosos: clientesMorosos.size,
      aging_buckets: buckets,
      proximos_vencimientos: proximosVencimientos,
      total_cobrado: Number(totalCobradoAgg._sum.monto_total || 0),
    };
  }

  /**
   * Genera un estado de cuenta para un cliente específico.
   * Incluye todas las facturas pendientes con sus cuotas y saldos.
   */
  async getEstadoCuentaCliente(
    empresaId: string,
    clienteId: string,
    options: { incluirItems?: boolean; usuarioId?: string } = {},
  ) {
    const { incluirItems = false, usuarioId } = options;
    // Mismo alcance por sucursal (y rubro del selector) que el resto de Cobranzas:
    // el usuario del Taller sólo ve las facturas y cobros del Taller.
    const alcance = await alcanceSucursalUsuario(this.prisma, usuarioId, empresaId);
    const filtroFacturas = await filtroFiscalPorSucursal(this.prisma, alcance);
    const filtroRecibos = filtroSucursalOperativo(alcance);
    const hoy = new Date();
    hoy.setHours(0, 0, 0, 0);

    // Obtener datos del cliente
    const cliente = await this.prisma.clientes.findUnique({
      where: { id: clienteId },
      include: { personas: true },
    });

    if (!cliente) {
      throw new NotFoundException('Cliente no encontrado');
    }

    // Obtener empresa
    const empresa = await this.prisma.empresas.findUnique({
      where: { id: empresaId },
      select: { razon_social: true, nombre_fantasia: true, ruc: true, dv: true, celular: true, email: true, logo: true },
    });

    // Obtener cuotas pendientes del cliente
    const cuotasPendientes = await this.prisma.factura_cuotas.findMany({
      where: {
        estado: { notIn: ['pagado', 'Pagado'] },
        factura_cab: {
          empresa_id: empresaId,
          cliente_id: clienteId,
          estado: { notIn: ESTADOS_FACTURA_NO_COBRABLE_PRISMA },
          ...(filtroFacturas ?? {}),
        },
      },
      include: {
        factura_cab: {
          include: {
            moneda: true,
            factura_subtotales: true,
            ...(incluirItems && {
              factura_det: {
                select: { ddesproser: true, dcantproser: true, item: true },
                orderBy: { item: 'asc' as const },
              },
            }),
          },
        },
      },
      orderBy: { dvenccuo: 'asc' },
    });

    // Agrupar por factura
    const facturasMap = new Map<
      string,
      {
        numero: string;
        fecha_emision: string;
        total_factura: number;
        moneda: string;
        cuotas: Array<{
          nro_cuota: number;
          fecha_vencimiento: string;
          monto_cuota: number;
          saldo_pendiente: number;
          dias_vencido: number;
          estado: string;
          /** Interés manual pendiente de esta cuota. */
          interes_manual: number;
        }>;
        /** Interés manual pendiente cargado a la cuenta (no cuelga de una cuota). */
        interes_manual_cuenta: number;
        items?: Array<{ descripcion: string; cantidad: number }>;
      }
    >();

    // Interés manual pendiente: la app del cobrador y la pantalla de cobro lo
    // muestran junto a la cuota (o a la cuenta) y lo cobran con ella.
    const interesManual = await this.interesManual.pendienteParaCobro(
      empresaId,
      cuotasPendientes.map((c) => c.id),
      [...new Set(cuotasPendientes.map((c) => c.factura_cab.id))],
    );

    let totalPendiente = 0;
    let totalVencido = 0;
    let totalAlDia = 0;

    for (const cuota of cuotasPendientes) {
      const factura = cuota.factura_cab;
      const facturaId = factura.id;
      const saldo = Number(cuota.saldo_pendiente) || Number(cuota.dmoncuota);
      const fechaVencimiento = new Date(cuota.dvenccuo);
      const diasVencido = Math.floor((hoy.getTime() - fechaVencimiento.getTime()) / (1000 * 60 * 60 * 24));
      const vencida = diasVencido > 0;

      totalPendiente += saldo;
      if (vencida) totalVencido += saldo;
      else totalAlDia += saldo;

      if (!facturasMap.has(facturaId)) {
        // Calcular total de la factura desde subtotales
        const totalFactura = factura.factura_subtotales?.reduce((sum, s) => sum + (Number(s.dtotope) || 0), 0) || 0;

        const facturaEntry: any = {
          numero: `${factura.dest}-${factura.dpunexp}-${factura.dnumdoc}`,
          fecha_emision: formatDateResponse(factura.dfeemide, true),
          total_factura: totalFactura,
          moneda: factura.moneda?.codigo || 'PYG',
          cuotas: [],
          interes_manual_cuenta: interesManual.porCuenta.get(facturaId) ?? 0,
        };

        if (incluirItems && (factura as any).factura_det) {
          facturaEntry.items = (
            (factura as any).factura_det as Array<{ ddesproser: string; dcantproser: any; item: number | null }>
          ).map((d) => ({
            descripcion: d.ddesproser,
            cantidad: Number(d.dcantproser),
          }));
        }

        facturasMap.set(facturaId, facturaEntry);
      }

      facturasMap.get(facturaId).cuotas.push({
        nro_cuota: cuota.nro_cuota,
        fecha_vencimiento: formatDateResponse(cuota.dvenccuo, true),
        monto_cuota: Number(cuota.dmoncuota),
        saldo_pendiente: saldo,
        dias_vencido: vencida ? diasVencido : 0,
        estado: vencida ? 'Vencida' : 'Pendiente',
        interes_manual: interesManual.porCuota.get(cuota.id) ?? 0,
      });
    }

    // Encabezado: si todas las facturas del estado de cuenta son de una misma
    // sucursal, logo y nombre comercial de esa sucursal; si mezcla, los de la empresa.
    const sucursalEmisora = await sucursalUnicaDeDocumentos(this.prisma, empresaId, {
      establecimientos: cuotasPendientes.map((c) => c.factura_cab.dest),
    });

    return {
      empresa: {
        razon_social: empresa?.razon_social,
        nombre_fantasia: nombreFantasiaEmisor(sucursalEmisora, empresa),
        ruc: empresa?.ruc ? `${empresa.ruc}-${empresa.dv}` : null,
        celular: empresa?.celular,
        email: empresa?.email,
        logo_url: logoEmisor(sucursalEmisora, empresa),
      },
      cliente: {
        razon_social: cliente.personas?.razon_social,
        ruc: cliente.personas?.ruc ? `${cliente.personas.ruc}-${cliente.personas.dv}` : null,
        telefono: cliente.personas?.telefono,
        direccion: cliente.personas?.direccion,
        cod_cliente: cliente.cod_cliente,
      },
      fecha_generacion: formatDateResponse(new Date(), true),
      resumen: {
        total_pendiente: totalPendiente,
        total_vencido: totalVencido,
        total_al_dia: totalAlDia,
        facturas_pendientes: facturasMap.size,
        cuotas_pendientes: cuotasPendientes.length,
        total_interes_manual: interesManual.total,
      },
      facturas: Array.from(facturasMap.values()),
      cobros: await this.getCobrosHistorialCliente(empresaId, clienteId, 20, filtroRecibos),
    };
  }

  /**
   * Retorna el historial de cobros de un cliente (recibos no anulados, agrupados por recibo).
   */
  async getCobrosHistorialCliente(
    empresaId: string,
    clienteId: string,
    take = 50,
    /** Alcance por sucursal ya resuelto (ver getEstadoCuentaCliente). */
    filtroSucursal?: string | { in: string[] },
  ) {
    const recibos = await this.prisma.recibos_cobro.findMany({
      where: {
        empresa_id: empresaId,
        cliente_id: clienteId,
        estado: { not: ReciboCobroEstado.ANULADO },
        ...(filtroSucursal !== undefined ? { sucursal_id: filtroSucursal } : {}),
      },
      include: {
        recibo_cobro_detalle: {
          include: {
            factura_cuotas: {
              include: {
                factura_cab: { select: { dest: true, dpunexp: true, dnumdoc: true } },
              },
            },
            medio_pago: { select: { descripcion: true } },
          },
        },
      },
      orderBy: { fecha_emision: 'desc' },
      take,
    });

    return recibos.map((r) => ({
      id: r.id,
      numero_recibo: r.numero_recibo,
      fecha: formatDateResponse(r.fecha_emision ?? (r as any).created_at, true),
      monto_total: Number(r.monto_total),
      descuento: Number((r as any).descuento_monto || 0),
      observacion: r.observacion || '',
      detalles: r.recibo_cobro_detalle.map((d) => ({
        factura: d.factura_cuotas?.factura_cab
          ? `${d.factura_cuotas.factura_cab.dest}-${d.factura_cuotas.factura_cab.dpunexp}-${d.factura_cuotas.factura_cab.dnumdoc}`
          : '-',
        nro_cuota: d.factura_cuotas?.nro_cuota ?? '-',
        monto_pagado: Number(d.monto_pagado),
        descuento_aplicado: Number((d as any).descuento_aplicado || 0),
        medio_pago: d.medio_pago?.descripcion || 'Efectivo',
      })),
    }));
  }

  /**
   * Aggrega el historial crediticio del cliente: facturado/cancelado lifetime,
   * % puntualidad, atraso prom/máx, aging vigente, mora, cheques rechazados,
   * línea de crédito y un score con semáforo (verde/amarillo/rojo).
   */
  async getHistorialCrediticio(empresaId: string, clienteId: string) {
    const hoy = new Date();
    hoy.setHours(0, 0, 0, 0);

    const cliente = await this.prisma.clientes.findUnique({
      where: { id: clienteId },
      include: { personas: true },
    });
    if (!cliente) throw new NotFoundException('Cliente no encontrado');

    const empresa = await this.prisma.empresas.findUnique({
      where: { id: empresaId },
      select: { razon_social: true, nombre_fantasia: true, ruc: true, dv: true, celular: true, email: true, logo: true },
    });

    type FacturadoRow = { total_facturado: string | null; total_facturas: bigint | null; primera: Date | null };
    const [facturadoRow] = await this.prisma.$queryRaw<FacturadoRow[]>`
      WITH base AS (
        SELECT
          fc.id,
          fc.dfeemide,
          COALESCE(
            NULLIF(fc.total_factura, 0),
            (SELECT COALESCE(SUM(fs.dtotope), 0) FROM factura_subtotales fs WHERE fs.factura_cab_id = fc.id),
            (SELECT COALESCE(SUM(cu.dmoncuota), 0) FROM factura_cuotas cu WHERE cu.factura_cab_id = fc.id),
            0
          ) AS total_calc
        FROM factura_cab fc
        WHERE fc.empresa_id = ${empresaId}::uuid
          AND fc.cliente_id = ${clienteId}::uuid
          AND ${sqlFacturaCobrable('fc', true)}
      )
      SELECT
        COALESCE(SUM(total_calc), 0)::text AS total_facturado,
        COUNT(*)::bigint AS total_facturas,
        MIN(dfeemide) AS primera
      FROM base
    `;

    type CobradoRow = { total_cobrado: string | null; total_recibos: bigint | null };
    const [cobradoRow] = await this.prisma.$queryRaw<CobradoRow[]>`
      SELECT
        COALESCE(SUM(monto_total), 0)::text AS total_cobrado,
        COUNT(*)::bigint AS total_recibos
      FROM recibos_cobro
      WHERE empresa_id = ${empresaId}::uuid
        AND cliente_id = ${clienteId}::uuid
        AND estado NOT IN ('anulado', 'Anulado')
    `;

    type PuntRow = { cuotas_pagadas: bigint; puntuales: bigint; atraso_prom: string | null; atraso_max: number | null };
    const [puntRow] = await this.prisma.$queryRaw<PuntRow[]>`
      SELECT
        COUNT(*)::bigint AS cuotas_pagadas,
        SUM(CASE WHEN DATE(rc.fecha_emision) <= cu.dvenccuo THEN 1 ELSE 0 END)::bigint AS puntuales,
        COALESCE(AVG(GREATEST(0, DATE(rc.fecha_emision) - cu.dvenccuo)), 0)::text AS atraso_prom,
        COALESCE(MAX(GREATEST(0, DATE(rc.fecha_emision) - cu.dvenccuo)), 0) AS atraso_max
      FROM recibo_cobro_detalle rcd
      JOIN recibos_cobro rc ON rc.id = rcd.recibo_cobro_id
      JOIN factura_cuotas cu ON cu.id = rcd.factura_cuota_id
      JOIN factura_cab fc ON fc.id = cu.factura_cab_id
      WHERE rc.empresa_id = ${empresaId}::uuid
        AND rc.cliente_id = ${clienteId}::uuid
        AND rc.estado NOT IN ('anulado', 'Anulado')
        AND fc.cliente_id = ${clienteId}::uuid
    `;

    type AgingRow = { bucket: string; total: string; cuotas: bigint };
    const agingRows = await this.prisma.$queryRaw<AgingRow[]>`
      SELECT
        CASE
          WHEN cu.dvenccuo >= CURRENT_DATE THEN 'vigente'
          WHEN CURRENT_DATE - cu.dvenccuo BETWEEN 1 AND 30 THEN '1_30'
          WHEN CURRENT_DATE - cu.dvenccuo BETWEEN 31 AND 60 THEN '31_60'
          WHEN CURRENT_DATE - cu.dvenccuo BETWEEN 61 AND 90 THEN '61_90'
          ELSE 'mas_90'
        END AS bucket,
        COALESCE(SUM(COALESCE(cu.saldo_pendiente, cu.dmoncuota)), 0)::text AS total,
        COUNT(*)::bigint AS cuotas
      FROM factura_cuotas cu
      JOIN factura_cab fc ON fc.id = cu.factura_cab_id
      WHERE fc.empresa_id = ${empresaId}::uuid
        AND fc.cliente_id = ${clienteId}::uuid
        AND ${sqlFacturaCobrable('fc', true)}
        AND LOWER(COALESCE(cu.estado, '')) NOT IN ('pagado')
      GROUP BY 1
    `;

    type FactCanceladaRow = { canceladas: bigint };
    const [{ canceladas }] = await this.prisma.$queryRaw<FactCanceladaRow[]>`
      SELECT COUNT(*)::bigint AS canceladas
      FROM factura_cab fc
      WHERE fc.empresa_id = ${empresaId}::uuid
        AND fc.cliente_id = ${clienteId}::uuid
        AND ${sqlFacturaCobrable('fc', true)}
        AND EXISTS (SELECT 1 FROM factura_cuotas cu WHERE cu.factura_cab_id = fc.id)
        AND NOT EXISTS (
          SELECT 1 FROM factura_cuotas cu
          WHERE cu.factura_cab_id = fc.id
            AND LOWER(COALESCE(cu.estado, '')) <> 'pagado'
        )
    `;

    type UltimoPagoRow = { fecha: Date | null; monto: string | null };
    const [ultimoPagoRow] = await this.prisma.$queryRaw<UltimoPagoRow[]>`
      SELECT
        MAX(fecha_emision) AS fecha,
        (SELECT monto_total::text FROM recibos_cobro
          WHERE empresa_id = ${empresaId}::uuid
            AND cliente_id = ${clienteId}::uuid
            AND estado NOT IN ('anulado', 'Anulado')
          ORDER BY fecha_emision DESC LIMIT 1) AS monto
      FROM recibos_cobro
      WHERE empresa_id = ${empresaId}::uuid
        AND cliente_id = ${clienteId}::uuid
        AND estado NOT IN ('anulado', 'Anulado')
    `;

    type ProxPagoRow = { fecha: Date | null; monto: string | null };
    const [proxPagoRow] = await this.prisma.$queryRaw<ProxPagoRow[]>`
      SELECT cu.dvenccuo AS fecha, COALESCE(cu.saldo_pendiente, cu.dmoncuota)::text AS monto
      FROM factura_cuotas cu
      JOIN factura_cab fc ON fc.id = cu.factura_cab_id
      WHERE fc.empresa_id = ${empresaId}::uuid
        AND fc.cliente_id = ${clienteId}::uuid
        AND ${sqlFacturaCobrable('fc', true)}
        AND LOWER(COALESCE(cu.estado, '')) <> 'pagado'
        AND cu.dvenccuo >= CURRENT_DATE
      ORDER BY cu.dvenccuo ASC
      LIMIT 1
    `;

    const razonSocial = cliente.personas?.razon_social || '';
    type ChequeRow = { cantidad: bigint; monto: string };
    const [chequeRow] = razonSocial
      ? await this.prisma.$queryRaw<ChequeRow[]>`
          SELECT COUNT(*)::bigint AS cantidad, COALESCE(SUM(monto), 0)::text AS monto
          FROM tes_cheques
          WHERE empresa_id = ${empresaId}::uuid
            AND estado IN ('RECHAZADO', 'DEVUELTO')
            AND LOWER(COALESCE(titular, '')) = LOWER(${razonSocial})
        `
      : [{ cantidad: 0n, monto: '0' } as ChequeRow];

    const totalFacturado = Number(facturadoRow?.total_facturado || 0);
    const totalCobrado = Number(cobradoRow?.total_cobrado || 0);
    const cuotasPagadas = Number(puntRow?.cuotas_pagadas || 0);
    const puntuales = Number(puntRow?.puntuales || 0);
    const atrasoPromedio = Number(puntRow?.atraso_prom || 0);
    const atrasoMaximo = Number(puntRow?.atraso_max || 0);
    const puntualidadPct = cuotasPagadas > 0 ? (puntuales / cuotasPagadas) * 100 : null;

    const agingMap: Record<string, { total: number; cuotas: number }> = {
      vigente: { total: 0, cuotas: 0 },
      '1_30': { total: 0, cuotas: 0 },
      '31_60': { total: 0, cuotas: 0 },
      '61_90': { total: 0, cuotas: 0 },
      mas_90: { total: 0, cuotas: 0 },
    };
    for (const r of agingRows) {
      agingMap[r.bucket] = { total: Number(r.total), cuotas: Number(r.cuotas) };
    }

    const saldoVigente = Object.values(agingMap).reduce((s, v) => s + v.total, 0);
    const saldoVencido =
      agingMap['1_30'].total + agingMap['31_60'].total + agingMap['61_90'].total + agingMap.mas_90.total;
    const mora30 = agingMap['31_60'].total + agingMap['61_90'].total + agingMap.mas_90.total;
    const mora60 = agingMap['61_90'].total + agingMap.mas_90.total;
    const mora90 = agingMap.mas_90.total;

    const limiteCredito = Number(cliente.limite_credito || 0);
    const utilizacionPct = limiteCredito > 0 ? (saldoVigente / limiteCredito) * 100 : null;

    const primera = facturadoRow?.primera
      ? new Date(facturadoRow.primera)
      : cliente.created_at
        ? new Date(cliente.created_at)
        : hoy;
    const antiguedadDias = Math.max(0, Math.floor((hoy.getTime() - primera.getTime()) / (1000 * 60 * 60 * 24)));

    const chequesRechazados = Number(chequeRow?.cantidad || 0);
    const chequesRechazadosMonto = Number(chequeRow?.monto || 0);

    // Score 0-100 ponderado
    const sPunt = puntualidadPct == null ? 50 : puntualidadPct; // sin historial = neutro
    const sAtraso =
      atrasoMaximo <= 0 ? 100 : atrasoMaximo <= 7 ? 85 : atrasoMaximo <= 30 ? 60 : atrasoMaximo <= 60 ? 35 : 10;
    const sUso =
      utilizacionPct == null
        ? 70
        : utilizacionPct <= 50
          ? 100
          : utilizacionPct <= 80
            ? 70
            : utilizacionPct <= 100
              ? 40
              : 10;
    const sCheques = chequesRechazados === 0 ? 100 : chequesRechazados === 1 ? 50 : 0;
    const sAntig =
      antiguedadDias >= 720
        ? 100
        : antiguedadDias >= 365
          ? 80
          : antiguedadDias >= 180
            ? 60
            : antiguedadDias >= 90
              ? 40
              : 20;
    const score = Math.round(sPunt * 0.4 + sAtraso * 0.2 + sUso * 0.2 + sCheques * 0.1 + sAntig * 0.1);
    const semaforo = score >= 75 ? 'VERDE' : score >= 50 ? 'AMARILLO' : 'ROJO';
    const recomendacion =
      semaforo === 'VERDE'
        ? 'Apto para ampliación de línea de crédito'
        : semaforo === 'AMARILLO'
          ? 'Mantener línea actual y monitorear comportamiento'
          : 'No recomendado para ampliar crédito; requiere garantías adicionales';

    return {
      empresa: {
        razon_social: empresa?.razon_social,
        // Métricas de toda la empresa: nombre comercial de la empresa arriba y razón social debajo.
        nombre_fantasia: empresa?.nombre_fantasia ?? null,
        ruc: empresa?.ruc ? `${empresa.ruc}-${empresa.dv}` : null,
        celular: empresa?.celular,
        email: empresa?.email,
        logo_url: empresa?.logo ?? null,
      },
      cliente: {
        razon_social: cliente.personas?.razon_social,
        ruc: cliente.personas?.ruc ? `${cliente.personas.ruc}-${cliente.personas.dv}` : null,
        telefono: cliente.personas?.telefono,
        direccion: cliente.personas?.direccion,
        cod_cliente: cliente.cod_cliente,
      },
      fecha_generacion: formatDateResponse(new Date(), true),
      score: {
        valor: score,
        semaforo,
        recomendacion,
      },
      perfil: {
        antiguedad_dias: antiguedadDias,
        primera_operacion: formatDateResponse(primera, true),
        bloqueado_credito: !!cliente.bloqueado_credito,
      },
      facturacion: {
        total_facturado: totalFacturado,
        total_cobrado: totalCobrado,
        total_facturas: Number(facturadoRow?.total_facturas || 0),
        cuentas_canceladas: Number(canceladas || 0),
      },
      comportamiento: {
        cuotas_pagadas: cuotasPagadas,
        cuotas_puntuales: puntuales,
        puntualidad_pct: puntualidadPct,
        atraso_promedio_dias: atrasoPromedio,
        atraso_maximo_dias: atrasoMaximo,
        dias_mora_maximo_registrado: cliente.dias_mora_maximo ?? 0,
        ultimo_pago_fecha: ultimoPagoRow?.fecha ? formatDateResponse(ultimoPagoRow.fecha, true) : null,
        ultimo_pago_monto: ultimoPagoRow?.monto ? Number(ultimoPagoRow.monto) : null,
        proximo_pago_fecha: proxPagoRow?.fecha ? formatDateResponse(proxPagoRow.fecha, true) : null,
        proximo_pago_monto: proxPagoRow?.monto ? Number(proxPagoRow.monto) : null,
      },
      aging: {
        vigente: agingMap.vigente,
        d1_30: agingMap['1_30'],
        d31_60: agingMap['31_60'],
        d61_90: agingMap['61_90'],
        mas_90: agingMap.mas_90,
        saldo_total: saldoVigente,
        saldo_vencido: saldoVencido,
        mora_mayor_30: mora30,
        mora_mayor_60: mora60,
        mora_mayor_90: mora90,
      },
      credito: {
        limite: limiteCredito,
        moneda: cliente.moneda_credito || 'PYG',
        saldo_pendiente: Number(cliente.saldo_pendiente || saldoVigente),
        utilizacion_pct: utilizacionPct,
        disponible: limiteCredito > 0 ? Math.max(0, limiteCredito - saldoVigente) : null,
      },
      cheques_rechazados: {
        cantidad: chequesRechazados,
        monto: chequesRechazadosMonto,
      },
      facturas_credito: await this.getFacturasCreditoHistorial(empresaId, clienteId),
    };
  }

  /**
   * Lista las facturas a crédito (con cuotas) del cliente, con su estado de cancelación,
   * saldo y mora actual. Limitado a las 40 más recientes para mantener el PDF acotado.
   */
  private async getFacturasCreditoHistorial(empresaId: string, clienteId: string) {
    type Row = {
      id: string;
      numero: string;
      fecha_emision: Date;
      total: string;
      saldo: string;
      cuotas_total: bigint;
      cuotas_pagadas: bigint;
      cuotas_vencidas: bigint;
      proximo_vencimiento: Date | null;
      dias_mora_max: number | null;
      ultimo_pago_fecha: Date | null;
      ultimo_pago_monto: string | null;
    };
    const rows = await this.prisma.$queryRaw<Row[]>`
      SELECT
        fc.id,
        (fc.dest || '-' || fc.dpunexp || '-' || fc.dnumdoc) AS numero,
        fc.dfeemide AS fecha_emision,
        COALESCE(
          NULLIF(fc.total_factura, 0),
          (SELECT COALESCE(SUM(fs.dtotope), 0) FROM factura_subtotales fs WHERE fs.factura_cab_id = fc.id),
          (SELECT COALESCE(SUM(cu2.dmoncuota), 0) FROM factura_cuotas cu2 WHERE cu2.factura_cab_id = fc.id),
          0
        )::text AS total,
        COALESCE(
          fc.saldo_pendiente,
          (SELECT COALESCE(SUM(COALESCE(cu3.saldo_pendiente, cu3.dmoncuota)), 0)
             FROM factura_cuotas cu3
             WHERE cu3.factura_cab_id = fc.id
               AND LOWER(COALESCE(cu3.estado, '')) <> 'pagado'),
          0
        )::text AS saldo,
        COUNT(cu.id)::bigint AS cuotas_total,
        SUM(CASE WHEN LOWER(COALESCE(cu.estado, '')) = 'pagado' THEN 1 ELSE 0 END)::bigint AS cuotas_pagadas,
        SUM(CASE WHEN LOWER(COALESCE(cu.estado, '')) <> 'pagado' AND cu.dvenccuo < CURRENT_DATE THEN 1 ELSE 0 END)::bigint AS cuotas_vencidas,
        MIN(CASE WHEN LOWER(COALESCE(cu.estado, '')) <> 'pagado' AND cu.dvenccuo >= CURRENT_DATE THEN cu.dvenccuo END) AS proximo_vencimiento,
        MAX(CASE WHEN LOWER(COALESCE(cu.estado, '')) <> 'pagado' THEN (CURRENT_DATE - cu.dvenccuo) END) AS dias_mora_max,
        (SELECT MAX(rc.fecha_emision)
           FROM recibo_cobro_detalle rcd
           JOIN recibos_cobro rc ON rc.id = rcd.recibo_cobro_id
           JOIN factura_cuotas cu4 ON cu4.id = rcd.factura_cuota_id
           WHERE cu4.factura_cab_id = fc.id
             AND rc.estado NOT IN ('anulado', 'Anulado')) AS ultimo_pago_fecha,
        (SELECT rcd.monto_pagado::text
           FROM recibo_cobro_detalle rcd
           JOIN recibos_cobro rc ON rc.id = rcd.recibo_cobro_id
           JOIN factura_cuotas cu5 ON cu5.id = rcd.factura_cuota_id
           WHERE cu5.factura_cab_id = fc.id
             AND rc.estado NOT IN ('anulado', 'Anulado')
           ORDER BY rc.fecha_emision DESC
           LIMIT 1) AS ultimo_pago_monto
      FROM factura_cab fc
      JOIN factura_cuotas cu ON cu.factura_cab_id = fc.id
      WHERE fc.empresa_id = ${empresaId}::uuid
        AND fc.cliente_id = ${clienteId}::uuid
        AND ${sqlFacturaCobrable('fc', true)}
      GROUP BY fc.id, fc.dest, fc.dpunexp, fc.dnumdoc, fc.dfeemide, fc.total_factura, fc.saldo_pendiente
      ORDER BY fc.dfeemide DESC
      LIMIT 40
    `;

    return rows.map((r) => {
      const saldo = Number(r.saldo);
      const cuotasVencidas = Number(r.cuotas_vencidas || 0);
      const diasMora = Number(r.dias_mora_max || 0);
      let estado: 'CANCELADA' | 'VENCIDA' | 'VIGENTE';
      if (saldo <= 0) estado = 'CANCELADA';
      else if (cuotasVencidas > 0 || diasMora > 0) estado = 'VENCIDA';
      else estado = 'VIGENTE';

      return {
        numero: r.numero,
        fecha_emision: formatDateResponse(r.fecha_emision, true),
        total: Number(r.total),
        saldo,
        cuotas_total: Number(r.cuotas_total || 0),
        cuotas_pagadas: Number(r.cuotas_pagadas || 0),
        cuotas_vencidas: cuotasVencidas,
        proximo_vencimiento: r.proximo_vencimiento ? formatDateResponse(r.proximo_vencimiento, true) : null,
        dias_mora: Math.max(0, diasMora),
        estado,
        ultimo_pago_fecha: r.ultimo_pago_fecha ? formatDateResponse(r.ultimo_pago_fecha, true) : null,
        ultimo_pago_monto: r.ultimo_pago_monto ? Number(r.ultimo_pago_monto) : null,
      };
    });
  }

  /**
   * Genera el PDF del estado de cuenta via msv-kude.
   * Sigue el mismo patrón que solicitudes-credito/generatePdf.
   */
  async generateEstadoCuentaPdf(
    empresaId: string,
    clienteId: string,
    options: { tipo_retorno?: string; formato?: string; incluir_todas?: boolean; usuarioId?: string } = {},
  ): Promise<{ pdfBuffer?: Buffer; base64?: string; contentType: string }> {
    const { tipo_retorno = 'view', formato = 'a4', incluir_todas = false, usuarioId } = options;

    let payload: Record<string, unknown>;
    let url: string;

    if (formato === 'historial') {
      const historial = await this.getHistorialCrediticio(empresaId, clienteId);
      payload = { tipo: tipo_retorno, formato, ...historial };
      url = `${envs.apiGeneradorPDF}/api/historial-crediticio/generate-pdf`;
    } else {
      const incluirItems = formato === 'a4-detallado';
      const estadoCuenta = await this.getEstadoCuentaCliente(empresaId, clienteId, { incluirItems, usuarioId });
      payload = {
        tipo: tipo_retorno,
        formato,
        incluir_todas,
        empresa: estadoCuenta.empresa,
        cliente: estadoCuenta.cliente,
        fecha_generacion: estadoCuenta.fecha_generacion,
        resumen: estadoCuenta.resumen,
        facturas: estadoCuenta.facturas,
        cobros: estadoCuenta.cobros,
      };
      url = `${envs.apiGeneradorPDF}/api/estado-cuenta/generate-pdf`;
    }

    try {
      const response = await fetch(url, {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify(payload),
      });

      if (!response.ok) {
        const errorBody = await response.text();
        throw new BadRequestException(`Error al generar PDF: ${errorBody}`);
      }

      if (tipo_retorno === 'base64') {
        const json = (await response.json()) as { status: string; pdf_base64?: string; message?: string };
        if (json.status !== 'success') throw new BadRequestException(json.message || 'Error al generar PDF');
        return { base64: json.pdf_base64, contentType: 'application/json' };
      }

      const arrayBuffer = await response.arrayBuffer();
      const pdfBuffer = Buffer.from(arrayBuffer);
      return { pdfBuffer, contentType: 'application/pdf' };
    } catch (error) {
      if (error instanceof BadRequestException) throw error;
      const message = error instanceof Error ? error.message : 'Error desconocido';
      throw new BadRequestException(`No se pudo generar el PDF: ${message}`);
    }
  }

  /**
   * Devuelve todos los ids de una categoría y su subárbol (incluye el propio id).
   * Usar en filtros para que al pick "Motos" también matchee productos en "Motos Nuevas", etc.
   */
  private async getCategoriaIdsRecursivo(empresaId: string, categoriaId: string): Promise<string[]> {
    // El filtro empresa_id se aplica solo en la raíz — para el subárbol confiamos en padre_id
    // (evita excluir categorías legítimas si tuvieran empresa_id null por migración/legacy).
    const rows = await this.prisma.$queryRaw<{ id: string }[]>`
      WITH RECURSIVE sub AS (
        SELECT id FROM categorias
          WHERE id = ${categoriaId}::uuid
            AND (empresa_id = ${empresaId}::uuid OR empresa_id IS NULL)
        UNION ALL
        SELECT c.id FROM categorias c
          JOIN sub ON c.padre_id = sub.id
          WHERE c.deleted_at = false
      )
      SELECT id FROM sub
    `;
    this.logger.log(
      `getCategoriaIdsRecursivo(${categoriaId}) → ${rows.length} ids: ${rows.map((r) => r.id).join(', ')}`,
    );
    return rows.map((r) => r.id);
  }

  async getCuentasCobrarPorCliente(empresaId: string, filters?: { categoriaId?: string }) {
    const categoriaIds = filters?.categoriaId
      ? await this.getCategoriaIdsRecursivo(empresaId, filters.categoriaId)
      : [];
    type RawRow = {
      id: string;
      cod_cliente: string | null;
      razon_social: string | null;
      ruc: string | null;
      dv: string | null;
      nro_documento: string | null;
      telefono: string | null;
      celular: string | null;
      email: string | null;
      direccion: string | null;
      saldo_total: string;
      saldo_vencido: string;
      cuotas_totales: string;
      cuotas_vencidas: string;
      facturas_pendientes: string;
      dias_mora: string | null;
      primer_vencimiento_vencido: Date | null;
    };

    // Notas: `factura_cab.total_notas_credito` guarda el monto acumulado de NC
    // aplicadas a la factura, pero las NC NO ajustan `factura_cuotas.saldo_pendiente`
    // (por diseño). Para el reporte de cartera se agrega en dos niveles:
    //   1) CTE `factura_agg`: por factura, suma bruta de cuotas pendientes + vencidas.
    //   2) Query externa: resta la NC del bruto y agrupa por cliente. Facturas
    //      cuya NC cubre todo el saldo bruto se excluyen (no adeudan más).
    // La NC se prioriza contra el vencido (criterio de aging) para no inflar
    // días de mora cuando el cliente ya fue "compensado" con una NC.
    const rows = await this.prisma.$queryRaw<RawRow[]>`
      WITH factura_agg AS (
        SELECT
          cab.id AS factura_id,
          cab.cliente_id,
          COALESCE(cab.total_notas_credito, 0) AS total_nc,
          SUM(COALESCE(fc.saldo_pendiente, fc.dmoncuota)) AS saldo_bruto,
          SUM(CASE WHEN fc.dvenccuo < CURRENT_DATE THEN COALESCE(fc.saldo_pendiente, fc.dmoncuota) ELSE 0 END) AS vencido_bruto,
          COUNT(fc.id) AS cuotas_totales,
          COUNT(CASE WHEN fc.dvenccuo < CURRENT_DATE THEN 1 END) AS cuotas_vencidas,
          MIN(CASE WHEN fc.dvenccuo < CURRENT_DATE THEN fc.dvenccuo END) AS primer_vencimiento_vencido
        FROM factura_cuotas fc
        JOIN factura_cab cab ON cab.id = fc.factura_cab_id
        WHERE fc.estado NOT IN ('pagado', 'Pagado')
          AND cab.empresa_id = ${empresaId}::uuid
          AND ${sqlFacturaCobrable('cab')}
          ${
            categoriaIds.length
              ? Prisma.sql`AND EXISTS (
            SELECT 1 FROM factura_det fd
            JOIN productos pr ON pr.id = fd.producto_id
            WHERE fd.factura_cab_id = cab.id
              AND pr.categoria_id IN (${Prisma.join(categoriaIds.map((id) => Prisma.sql`${id}::uuid`))})
          )`
              : Prisma.empty
          }
        GROUP BY cab.id, cab.cliente_id, cab.total_notas_credito
      )
      SELECT
        cl.id,
        cl.cod_cliente,
        p.razon_social,
        p.ruc,
        p.dv,
        p.nro_documento,
        p.telefono,
        p.celular,
        p.email,
        p.direccion,
        SUM(GREATEST(0, fa.saldo_bruto - fa.total_nc)) AS saldo_total,
        SUM(GREATEST(0, fa.vencido_bruto - fa.total_nc)) AS saldo_vencido,
        SUM(fa.cuotas_totales) AS cuotas_totales,
        SUM(fa.cuotas_vencidas) AS cuotas_vencidas,
        COUNT(DISTINCT fa.factura_id) AS facturas_pendientes,
        GREATEST(0, FLOOR(DATE_PART('day', NOW() - MIN(CASE WHEN (fa.vencido_bruto - fa.total_nc) > 0 THEN fa.primer_vencimiento_vencido END)))) AS dias_mora,
        MIN(CASE WHEN (fa.vencido_bruto - fa.total_nc) > 0 THEN fa.primer_vencimiento_vencido END) AS primer_vencimiento_vencido
      FROM factura_agg fa
      JOIN clientes cl ON cl.id = fa.cliente_id
      JOIN personas p ON p.id = cl.persona_id
      WHERE (fa.saldo_bruto - fa.total_nc) > 0
      GROUP BY cl.id, cl.cod_cliente, p.razon_social, p.ruc, p.dv, p.nro_documento,
               p.telefono, p.celular, p.email, p.direccion
      ORDER BY saldo_vencido DESC
    `;

    const clienteIds = rows.map((r) => r.id);
    const cuentasConRevision = await this.prisma.cuentas_cobrar.findMany({
      where: { cliente_id: { in: clienteIds }, empresa_id: empresaId, revisado: true },
      select: { cliente_id: true },
    });
    const revisadoSet = new Set(cuentasConRevision.map((r) => r.cliente_id).filter(Boolean));

    // Gestión de mora activa por cliente (un cliente puede tener una sola gestión activa por estado;
    // tomamos la más reciente). Permite mostrar chip "EN GESTIÓN · {estado}" y filtrar en frontend.
    const MORA_ACTIVOS: CobMoraEstado[] = [
      CobMoraEstado.GESTION_INTERNA,
      CobMoraEstado.INFORMCONF,
      CobMoraEstado.DEMANDA,
    ];
    const gestionesActivas = await this.prisma.cob_gestion_mora.findMany({
      where: {
        empresa_id: empresaId,
        cliente_id: { in: clienteIds },
        deleted_at: null,
        estado_actual: { in: MORA_ACTIVOS },
      },
      select: { id: true, cliente_id: true, estado_actual: true, fecha_ingreso: true },
      orderBy: { fecha_ingreso: 'desc' },
    });
    const moraByCliente = new Map<string, { id: string; estado: CobMoraEstado; fecha_ingreso: string }>();
    for (const g of gestionesActivas) {
      if (!moraByCliente.has(g.cliente_id)) {
        moraByCliente.set(g.cliente_id, {
          id: g.id,
          estado: g.estado_actual,
          fecha_ingreso: g.fecha_ingreso.toISOString(),
        });
      }
    }

    return rows.map((r) => ({
      id: r.id,
      cod_cliente: r.cod_cliente,
      razon_social: r.razon_social,
      ruc: r.ruc,
      dv: r.dv,
      nro_documento: r.nro_documento,
      telefono: r.telefono,
      celular: r.celular,
      email: r.email,
      direccion: r.direccion,
      saldo_total: Number(r.saldo_total),
      saldo_vencido: Number(r.saldo_vencido),
      cuotas_totales: Number(r.cuotas_totales),
      cuotas_vencidas: Number(r.cuotas_vencidas),
      facturas_pendientes: Number(r.facturas_pendientes),
      dias_mora: r.dias_mora !== null ? Number(r.dias_mora) : 0,
      primer_vencimiento_vencido: r.primer_vencimiento_vencido
        ? new Date(r.primer_vencimiento_vencido).toISOString().split('T')[0]
        : null,
      revisado: revisadoSet.has(r.id),
      gestion_mora_activa: moraByCliente.get(r.id) ?? null,
    }));
  }

  /**
   * Detalle mensual de pagos por factura para exportar a Excel.
   *
   * Devuelve una fila por factura con:
   *  - datos cliente/factura
   *  - monto original, total pagado, total NC, saldo actual
   *  - saldo al corte (si `fechaCorte`: total_factura - pagos_hasta_corte - nc_hasta_corte)
   *  - pagos agrupados por mes (YYYY-MM)
   *  - meses: array de YYYY-MM entre fecha_desde y fecha_corte para armar columnas dinámicas
   */
  async getDetalleMensualFacturas(
    empresaId: string,
    filters: {
      categoriaId?: string;
      fechaCorte?: string;
      fechaDesde?: string;
      fechaHasta?: string;
      soloConSaldo?: boolean;
    },
  ) {
    type FacturaRow = {
      factura_id: string;
      numero: string;
      fecha_emision: Date;
      cliente_id: string;
      cod_cliente: string | null;
      razon_social: string | null;
      ruc: string | null;
      dv: string | null;
      nro_documento: string | null;
      telefono: string | null;
      celular: string | null;
      moneda_codigo: string | null;
      condicion_codigo: string | null;
      total_factura: string;
      total_pagado: string;
      total_nc: string;
      saldo_actual: string;
      pagado_hasta_corte: string;
      nc_hasta_corte: string;
    };

    const corte = filters.fechaCorte ? new Date(filters.fechaCorte) : null;
    const categoriaIds = filters.categoriaId ? await this.getCategoriaIdsRecursivo(empresaId, filters.categoriaId) : [];
    this.logger.log(
      `[detalle-mensual] empresa=${empresaId} categoriaId=${filters.categoriaId} → ${categoriaIds.length} ids resueltos · fecha ${filters.fechaDesde || '—'}..${filters.fechaHasta || '—'} · corte=${filters.fechaCorte || '—'} · soloConSaldo=${filters.soloConSaldo}`,
    );

    const facturas = await this.prisma.$queryRaw<FacturaRow[]>`
      SELECT
        cab.id                                                                   AS factura_id,
        cab.dest || '-' || cab.dpunexp || '-' || cab.dnumdoc                     AS numero,
        cab.dfeemide                                                             AS fecha_emision,
        cl.id                                                                    AS cliente_id,
        cl.cod_cliente                                                           AS cod_cliente,
        p.razon_social,
        p.ruc,
        p.dv,
        p.nro_documento,
        p.telefono,
        p.celular,
        m.codigo                                                                 AS moneda_codigo,
        co.codigo                                                                AS condicion_codigo,
        COALESCE(cab.total_factura, sub.dtotgralope, sub.dtotope, 0)             AS total_factura,
        GREATEST(0,
          COALESCE(cab.total_factura, sub.dtotgralope, sub.dtotope, 0)
          - COALESCE(cab.saldo_pendiente, cab.total_factura, sub.dtotgralope, sub.dtotope, 0)
          - COALESCE(cab.total_notas_credito, 0)
        )                                                                        AS total_pagado,
        COALESCE(cab.total_notas_credito, 0)                                     AS total_nc,
        COALESCE(cab.saldo_pendiente, cab.total_factura, sub.dtotgralope, sub.dtotope, 0) AS saldo_actual,
        ${
          corte
            ? Prisma.sql`(
            COALESCE((
              SELECT SUM(rf.monto_pagado)
              FROM recibo_cobro_facturas rf
              JOIN recibos_cobro r ON r.id = rf.recibo_cobro_id
              WHERE rf.factura_cab_id = cab.id
                AND r.estado = 'emitido'
                AND r.fecha_emision::date <= ${corte}::date
            ), 0) + COALESCE((
              SELECT SUM(rcd.monto_pagado)
              FROM recibo_cobro_detalle rcd
              JOIN factura_cuotas fcu ON fcu.id = rcd.factura_cuota_id
              JOIN recibos_cobro r ON r.id = rcd.recibo_cobro_id
              WHERE fcu.factura_cab_id = cab.id
                AND r.estado = 'emitido'
                AND r.fecha_emision::date <= ${corte}::date
            ), 0)
          )`
            : Prisma.sql`0`
        }                                                       AS pagado_hasta_corte,
        ${
          corte
            ? Prisma.sql`COALESCE((
            SELECT SUM(nca.monto)
            FROM recibo_cobro_nc_aplicadas nca
            JOIN recibo_cobro_facturas rf ON rf.id = nca.recibo_factura_id
            JOIN recibos_cobro r ON r.id = rf.recibo_cobro_id
            WHERE rf.factura_cab_id = cab.id
              AND r.estado = 'emitido'
              AND r.fecha_emision::date <= ${corte}::date
          ), 0)`
            : Prisma.sql`0`
        }                                                       AS nc_hasta_corte
      FROM factura_cab cab
      JOIN clientes cl ON cl.id = cab.cliente_id
      JOIN personas p  ON p.id  = cl.persona_id
      LEFT JOIN factura_subtotales sub ON sub.factura_cab_id = cab.id
      LEFT JOIN moneda m ON m.id = cab.moneda_id
      LEFT JOIN condicion_operacion co ON co.id = cab.condicion_operacion_id
      WHERE cab.empresa_id = ${empresaId}::uuid
        AND ${sqlFacturaCobrable('cab')}
        ${filters.fechaDesde ? Prisma.sql`AND cab.dfeemide::date >= ${filters.fechaDesde}::date` : Prisma.empty}
        ${filters.fechaHasta ? Prisma.sql`AND cab.dfeemide::date <= ${filters.fechaHasta}::date` : Prisma.empty}
        ${
          categoriaIds.length
            ? Prisma.sql`AND EXISTS (
          SELECT 1 FROM factura_det fd
          JOIN productos pr ON pr.id = fd.producto_id
          WHERE fd.factura_cab_id = cab.id
            AND pr.categoria_id IN (${Prisma.join(categoriaIds.map((id) => Prisma.sql`${id}::uuid`))})
        )`
            : Prisma.empty
        }
      ORDER BY cab.dfeemide ASC, cab.dnumdoc ASC
    `;

    this.logger.log(`[detalle-mensual] query devolvió ${facturas.length} facturas`);
    if (!facturas.length) return { facturas: [], meses: [], filtros: filters };

    const facturaIds = facturas.map((f) => f.factura_id);

    // Todos los pagos aplicados a estas facturas (MULTI + LEGACY), con fecha del recibo
    type PagoRow = {
      factura_id: string;
      fecha: Date;
      monto: string;
      recibo_numero: string | null;
    };
    const pagosMulti = await this.prisma.$queryRaw<PagoRow[]>`
      SELECT
        rf.factura_cab_id                                    AS factura_id,
        r.fecha_emision::date                                AS fecha,
        rf.monto_pagado                                      AS monto,
        r.numero_recibo                                      AS recibo_numero
      FROM recibo_cobro_facturas rf
      JOIN recibos_cobro r ON r.id = rf.recibo_cobro_id
      WHERE rf.factura_cab_id IN (${Prisma.join(facturaIds.map((id) => Prisma.sql`${id}::uuid`))})
        AND r.estado = 'emitido'
        ${corte ? Prisma.sql`AND r.fecha_emision::date <= ${corte}::date` : Prisma.empty}
    `;
    const pagosLegacy = await this.prisma.$queryRaw<PagoRow[]>`
      SELECT
        fcu.factura_cab_id                                   AS factura_id,
        r.fecha_emision::date                                AS fecha,
        rcd.monto_pagado                                     AS monto,
        r.numero_recibo                                      AS recibo_numero
      FROM recibo_cobro_detalle rcd
      JOIN factura_cuotas fcu ON fcu.id = rcd.factura_cuota_id
      JOIN recibos_cobro r ON r.id = rcd.recibo_cobro_id
      WHERE fcu.factura_cab_id IN (${Prisma.join(facturaIds.map((id) => Prisma.sql`${id}::uuid`))})
        AND r.estado = 'emitido'
        ${corte ? Prisma.sql`AND r.fecha_emision::date <= ${corte}::date` : Prisma.empty}
    `;

    // Agrupar por factura + YYYY-MM
    const pagosPorFacturaYMes = new Map<string, Map<string, number>>();
    const mesesSet = new Set<string>();
    for (const p of [...pagosMulti, ...pagosLegacy]) {
      const ym = new Date(p.fecha).toISOString().slice(0, 7);
      mesesSet.add(ym);
      let m = pagosPorFacturaYMes.get(p.factura_id);
      if (!m) {
        m = new Map();
        pagosPorFacturaYMes.set(p.factura_id, m);
      }
      m.set(ym, (m.get(ym) ?? 0) + Number(p.monto));
    }

    // Meses: secuencia continua desde la emisión más antigua (o fechaDesde) hasta fechaCorte/fechaHasta.
    // Así el usuario ve una columna por cada mes del período, aunque no haya habido pagos ese mes.
    const primeraEmision = facturas.map((f) => f.fecha_emision.toISOString().slice(0, 7)).sort()[0];
    const inicioYM = (filters.fechaDesde ?? primeraEmision).slice(0, 7);
    const finYM = (filters.fechaCorte ?? filters.fechaHasta ?? new Date().toISOString().slice(0, 10)).slice(0, 7);
    const meses: string[] = [];
    let [yr, mo] = inicioYM.split('-').map(Number);
    const [yrFin, moFin] = finYM.split('-').map(Number);
    while (yr < yrFin || (yr === yrFin && mo <= moFin)) {
      meses.push(`${yr}-${String(mo).padStart(2, '0')}`);
      mo++;
      if (mo > 12) {
        mo = 1;
        yr++;
      }
      if (meses.length > 240) break; // salvaguarda anti loops
    }
    for (const ym of mesesSet) {
      if (!meses.includes(ym)) meses.push(ym);
    }
    meses.sort();

    // Filtrar por saldo al corte si se pidió
    const rows = facturas.map((f) => {
      const total = Number(f.total_factura);
      const pagadoCorte = Number(f.pagado_hasta_corte);
      const ncCorte = Number(f.nc_hasta_corte);
      const saldoCorte = corte ? Math.max(0, total - pagadoCorte - ncCorte) : Number(f.saldo_actual);
      const pagosMes: Record<string, number> = {};
      const mapa = pagosPorFacturaYMes.get(f.factura_id);
      if (mapa) for (const [ym, monto] of mapa) pagosMes[ym] = monto;

      return {
        factura_id: f.factura_id,
        numero: f.numero,
        fecha_emision: f.fecha_emision.toISOString().slice(0, 10),
        cliente_id: f.cliente_id,
        cod_cliente: f.cod_cliente,
        razon_social: f.razon_social,
        ruc: f.ruc,
        dv: f.dv,
        nro_documento: f.nro_documento,
        telefono: f.telefono,
        celular: f.celular,
        moneda_codigo: f.moneda_codigo,
        total_factura: total,
        total_pagado: Number(f.total_pagado),
        total_nc: Number(f.total_nc),
        saldo_actual: Number(f.saldo_actual),
        pagado_hasta_corte: pagadoCorte,
        nc_hasta_corte: ncCorte,
        saldo_corte: saldoCorte,
        pagos_por_mes: pagosMes,
      };
    });

    const filtradas = filters.soloConSaldo ? rows.filter((r) => r.saldo_corte > 0) : rows;

    return { facturas: filtradas, meses, filtros: filters };
  }

  /** Exporta el detalle mensual como PDF vía msv-kude (reporte-tabla genérico). */
  async exportarDetalleMensualPDF(
    empresaId: string,
    filters: {
      categoriaId?: string;
      fechaCorte?: string;
      fechaDesde?: string;
      fechaHasta?: string;
      soloConSaldo?: boolean;
    },
  ): Promise<Buffer> {
    const data = await this.getDetalleMensualFacturas(empresaId, filters);
    const facturas = data.facturas;
    const meses = data.meses.filter((ym) => (filters.fechaCorte ? ym <= filters.fechaCorte.slice(0, 7) : true));

    const empresa: any = await this.prisma.empresas.findUnique({ where: { id: empresaId } });

    // Categoría (label bonito con path completo)
    let categoriaLabel = 'Todas las categorías';
    if (filters.categoriaId) {
      const cat = await this.prisma.categorias.findUnique({
        where: { id: filters.categoriaId },
        select: { descripcion: true },
      });
      if (cat) categoriaLabel = cat.descripcion;
    }

    const mesesNombres = ['Ene', 'Feb', 'Mar', 'Abr', 'May', 'Jun', 'Jul', 'Ago', 'Sep', 'Oct', 'Nov', 'Dic'];
    const fmtMes = (ym: string) => {
      const [y, m] = ym.split('-');
      return `${mesesNombres[Number(m) - 1]}-${y.slice(2)}`;
    };

    // Columns: fijas + meses + fijas cierre
    const columns: any[] = [
      { key: 'fecha_emision', label: 'F. Emisión', type: 'date', weight: 1.2 },
      { key: 'razon_social', label: 'Cliente', weight: 3.2 },
      { key: 'ruc_display', label: 'CI / RUC', weight: 1.2 },
      { key: 'factura', label: 'Factura', weight: 1.5 },
      { key: 'total_factura', label: 'Monto Orig.', type: 'number', weight: 1.3 },
    ];
    for (const ym of meses) {
      columns.push({ key: `mes_${ym}`, label: fmtMes(ym), type: 'number', weight: 1.1 });
    }
    columns.push(
      { key: 'pagado_hasta_corte', label: 'Pagado (corte)', type: 'number', weight: 1.3 },
      { key: 'nc_hasta_corte', label: 'NC (corte)', type: 'number', weight: 1.1 },
      { key: 'saldo_corte', label: 'Saldo (corte)', type: 'number', weight: 1.3 },
    );

    // Rows
    const rows = facturas.map((f) => {
      const rucDisplay = f.ruc ? `${f.ruc}-${f.dv || ''}` : f.nro_documento || '';
      const base: any = {
        fecha_emision: f.fecha_emision,
        razon_social: f.razon_social || '',
        ruc_display: rucDisplay,
        factura: f.numero,
        total_factura: Number(f.total_factura || 0),
        pagado_hasta_corte: Number(f.pagado_hasta_corte || 0),
        nc_hasta_corte: Number(f.nc_hasta_corte || 0),
        saldo_corte: Number(f.saldo_corte || 0),
      };
      for (const ym of meses) {
        base[`mes_${ym}`] = Number(f.pagos_por_mes?.[ym] || 0);
      }
      return base;
    });

    const totales = [
      { label: 'Monto original', value: rows.reduce((a, r) => a + r.total_factura, 0), type: 'currency' },
      { label: 'Pagado al corte', value: rows.reduce((a, r) => a + r.pagado_hasta_corte, 0), type: 'currency' },
      { label: 'NC al corte', value: rows.reduce((a, r) => a + r.nc_hasta_corte, 0), type: 'currency' },
      { label: 'Saldo al corte', value: rows.reduce((a, r) => a + r.saldo_corte, 0), type: 'currency' },
    ];

    // Si hay >5 meses, usar A3 landscape; caso contrario A4 landscape
    const pageSize = meses.length > 5 ? 'A3' : 'A4';

    const payload: Record<string, unknown> = {
      titulo: `Detalle mensual de deudas — ${categoriaLabel}`,
      empresa: {
        razon_social: empresa?.razon_social ?? empresa?.nombre_fantasia ?? '',
        // Nombre comercial arriba y razón social debajo en el PDF (criterio de la factura).
        nombre_fantasia: empresa?.nombre_fantasia ?? null,
        ruc: empresa?.ruc ?? '',
        dv: empresa?.dv ?? '0',
        direccion: empresa?.direccion ?? '',
        telefono: empresa?.telefono ?? '',
        email: empresa?.email ?? '',
        logo_url: empresa?.logo_url ?? empresa?.logo ?? '',
      },
      desde: filters.fechaDesde ?? null,
      hasta: filters.fechaHasta ?? null,
      generado_en: new Date(),
      moneda: 'PYG',
      columns,
      rows,
      totales,
      pageSize,
      orientation: 'landscape',
      tipo: 'view',
    };

    const url = `${envs.apiGeneradorPDF}/api/reporte-tabla/generate-pdf`;
    const response = await fetch(url, {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify(payload),
    });
    if (!response.ok) {
      const errText = await response.text();
      this.logger.error(`msv-kude reporte-tabla ${response.status}: ${errText}`);
      throw new HttpException({ message: 'Error al generar PDF', detail: errText }, HttpStatus.BAD_GATEWAY);
    }
    const ab = await response.arrayBuffer();
    return Buffer.from(ab);
  }

  // ==================== CIERRE DE COBROS ====================

  /**
   * Reporte de cierre de cobros: por día, medios de pago y condición de pago
   * (Contado / Semanal / Quincenal / Mensual / Otro), derivada de la cadencia
   * de las cuotas de la factura pagada.
   */
  async getCierreCobros(
    empresaId: string,
    filters: {
      fechaDesde?: string;
      fechaHasta?: string;
      cobradorId?: string;
      sucursalId?: string;
      monedaId?: string;
    },
  ) {
    type ReciboRow = {
      recibo_id: string;
      numero_recibo: string;
      fecha: Date;
      cliente_razon: string | null;
      cobrador_nombre: string | null;
      usuario_nombre: string | null;
      modo: string;
      monto_total: string;
      moneda_codigo: string | null;
    };

    const recibos = await this.prisma.$queryRaw<ReciboRow[]>`
      SELECT
        r.id                                                    AS recibo_id,
        r.numero_recibo,
        r.fecha_emision::date                                   AS fecha,
        p.razon_social                                          AS cliente_razon,
        TRIM(CONCAT(COALESCE(vc.nombre, ''), ' ', COALESCE(vc.apellido, '')))  AS cobrador_nombre,
        TRIM(CONCAT(COALESCE(u.nombres, ''), ' ', COALESCE(u.apellidos, '')))  AS usuario_nombre,
        r.modo,
        r.monto_total,
        m.codigo                                                AS moneda_codigo
      FROM recibos_cobro r
      JOIN clientes cl ON cl.id = r.cliente_id
      JOIN personas p  ON p.id  = cl.persona_id
      LEFT JOIN vendedores_cobradores vc ON vc.id = r.cobrador_id
      LEFT JOIN usuario u ON u.id = r.usuario_id
      LEFT JOIN moneda m ON m.id = r.moneda_id
      WHERE r.empresa_id = ${empresaId}::uuid
        AND r.estado = ${ReciboCobroEstado.EMITIDO}
        ${filters.fechaDesde ? Prisma.sql`AND r.fecha_emision::date >= ${filters.fechaDesde}::date` : Prisma.empty}
        ${filters.fechaHasta ? Prisma.sql`AND r.fecha_emision::date <= ${filters.fechaHasta}::date` : Prisma.empty}
        ${filters.cobradorId ? Prisma.sql`AND r.cobrador_id = ${filters.cobradorId}::uuid` : Prisma.empty}
        ${filters.sucursalId ? Prisma.sql`AND r.sucursal_id = ${filters.sucursalId}::uuid` : Prisma.empty}
        ${filters.monedaId ? Prisma.sql`AND r.moneda_id = ${filters.monedaId}::uuid` : Prisma.empty}
      ORDER BY r.fecha_emision ASC
    `;

    if (!recibos.length) {
      return {
        recibos: [],
        porDia: [],
        porCondicion: [],
        porMedio: [],
        totalGeneral: 0,
        mediosBuckets: [],
        porMonedaTotales: [],
        filtros: filters,
      };
    }

    const reciboIds = recibos.map((r) => r.recibo_id);
    const idList = Prisma.join(reciboIds.map((id) => Prisma.sql`${id}::uuid`));

    // Líneas de factura aplicadas por recibo (MULTI + LEGACY unificado)
    type LineaRow = {
      recibo_id: string;
      factura_cab_id: string;
      monto_pagado: string;
      condicion_codigo: number | null;
      dcuotas: number | null;
      cuotas_count: number;
      avg_dias_intervalo: number | null;
    };

    const lineas = await this.prisma.$queryRaw<LineaRow[]>`
      WITH lineas AS (
        SELECT rf.recibo_cobro_id AS recibo_id, rf.factura_cab_id, rf.monto_pagado
        FROM recibo_cobro_facturas rf
        WHERE rf.recibo_cobro_id IN (${idList})
        UNION ALL
        SELECT rcd.recibo_cobro_id AS recibo_id, fcu.factura_cab_id, rcd.monto_pagado
        FROM recibo_cobro_detalle rcd
        JOIN factura_cuotas fcu ON fcu.id = rcd.factura_cuota_id
        WHERE rcd.recibo_cobro_id IN (${idList})
      ),
      cuotas_stats AS (
        SELECT
          factura_cab_id,
          COUNT(*)::int                                                        AS cuotas_count,
          CASE
            WHEN COUNT(*) <= 1 THEN NULL
            ELSE ((MAX(dvenccuo) - MIN(dvenccuo))::numeric / NULLIF(COUNT(*) - 1, 0))::int
          END                                                                  AS avg_dias_intervalo
        FROM factura_cuotas
        WHERE factura_cab_id IN (SELECT DISTINCT factura_cab_id FROM lineas)
        GROUP BY factura_cab_id
      )
      SELECT
        l.recibo_id,
        l.factura_cab_id,
        l.monto_pagado,
        co.codigo                                                              AS condicion_codigo,
        cab.dcuotas,
        COALESCE(cs.cuotas_count, 0)                                           AS cuotas_count,
        cs.avg_dias_intervalo
      FROM lineas l
      JOIN factura_cab cab ON cab.id = l.factura_cab_id
      LEFT JOIN condicion_operacion co ON co.id = cab.condicion_operacion_id
      LEFT JOIN cuotas_stats cs ON cs.factura_cab_id = l.factura_cab_id
    `;

    // Medios de pago (MULTI + LEGACY)
    type MedioRow = { recibo_id: string; medio: string; monto: string };
    const mediosMulti = await this.prisma.$queryRaw<MedioRow[]>`
      SELECT mp.recibo_cobro_id AS recibo_id, mp.medio, mp.monto
      FROM recibo_cobro_medios_pago mp
      WHERE mp.recibo_cobro_id IN (${idList})
    `;
    const mediosLegacy = await this.prisma.$queryRaw<MedioRow[]>`
      SELECT
        rcd.recibo_cobro_id AS recibo_id,
        COALESCE(mp.descripcion, 'Otro') AS medio,
        SUM(rcd.monto_pagado)::text AS monto
      FROM recibo_cobro_detalle rcd
      LEFT JOIN medio_pago mp ON mp.id = rcd.medio_pago_id
      WHERE rcd.recibo_cobro_id IN (${idList})
      GROUP BY rcd.recibo_cobro_id, mp.descripcion
    `;

    // Helper: label de condición de pago según cadencia
    const condLabel = (cond?: number | null, cuotasCount = 0, avgDias?: number | null): string => {
      if (cond === 1) return 'Contado';
      if (cuotasCount <= 1) return 'Contado / 1 pago';
      if (avgDias == null) return 'Otro';
      if (avgDias <= 10) return 'Semanal';
      if (avgDias <= 20) return 'Quincenal';
      if (avgDias <= 45) return 'Mensual';
      if (avgDias <= 95) return 'Bimestral';
      return 'Otro';
    };

    // Categorizar medio a un bucket estable
    const bucketMedio = (medio: string): string => {
      const m = medio.toUpperCase();
      if (m.includes('EFECTIVO')) return 'Efectivo';
      if (m.includes('CHEQUE')) return 'Cheque';
      if (m.includes('TRANSFER') || m.includes('BANCO')) return 'Transferencia';
      if (m.includes('TARJETA') || m.includes('CRED') || m.includes('DEB')) return 'Tarjeta';
      if (m.includes('NC') || m.includes('CREDITO')) return 'NC / Saldo a favor';
      if (m.includes('RETEN')) return 'Retención';
      return 'Otros';
    };

    const MEDIOS_BUCKETS = [
      'Efectivo',
      'Cheque',
      'Transferencia',
      'Tarjeta',
      'NC / Saldo a favor',
      'Retención',
      'Otros',
    ];

    // Agrupamiento por día
    const porDiaMap = new Map<
      string,
      {
        fecha: string;
        recibos: number;
        total_cobrado: number;
        por_medio: Record<string, number>;
      }
    >();
    for (const r of recibos) {
      const key = new Date(r.fecha).toISOString().slice(0, 10);
      let d = porDiaMap.get(key);
      if (!d) {
        d = {
          fecha: key,
          recibos: 0,
          total_cobrado: 0,
          por_medio: Object.fromEntries(MEDIOS_BUCKETS.map((m) => [m, 0])),
        };
        porDiaMap.set(key, d);
      }
      d.recibos += 1;
      d.total_cobrado += Number(r.monto_total);
    }

    // Distribuir medios en su día
    const reciboFechaMap = new Map(recibos.map((r) => [r.recibo_id, new Date(r.fecha).toISOString().slice(0, 10)]));
    for (const m of [...mediosMulti, ...mediosLegacy]) {
      const key = reciboFechaMap.get(m.recibo_id);
      if (!key) continue;
      const d = porDiaMap.get(key);
      if (!d) continue;
      const bucket = bucketMedio(m.medio || '');
      d.por_medio[bucket] = (d.por_medio[bucket] || 0) + Number(m.monto);
    }

    // Agrupamiento por condición de pago
    const porCondicionMap = new Map<
      string,
      { condicion: string; recibos: Set<string>; monto: number; cantidad_lineas: number }
    >();
    for (const l of lineas) {
      const label = condLabel(l.condicion_codigo, Number(l.cuotas_count || 0), l.avg_dias_intervalo);
      let entry = porCondicionMap.get(label);
      if (!entry) {
        entry = { condicion: label, recibos: new Set(), monto: 0, cantidad_lineas: 0 };
        porCondicionMap.set(label, entry);
      }
      entry.recibos.add(l.recibo_id);
      entry.monto += Number(l.monto_pagado);
      entry.cantidad_lineas += 1;
    }

    // Agrupamiento por medio de pago (totales globales)
    const porMedioMap = new Map<string, number>();
    for (const m of [...mediosMulti, ...mediosLegacy]) {
      const bucket = bucketMedio(m.medio || '');
      porMedioMap.set(bucket, (porMedioMap.get(bucket) || 0) + Number(m.monto));
    }

    const porDia = [...porDiaMap.values()].sort((a, b) => a.fecha.localeCompare(b.fecha));
    const porCondicion = [...porCondicionMap.values()]
      .map((c) => ({
        condicion: c.condicion,
        cantidad_recibos: c.recibos.size,
        cantidad_lineas: c.cantidad_lineas,
        monto: c.monto,
      }))
      .sort((a, b) => b.monto - a.monto);
    const porMedio = [...porMedioMap.entries()]
      .map(([medio, monto]) => ({ medio, monto }))
      .sort((a, b) => b.monto - a.monto);

    const totalGeneral = recibos.reduce((a, r) => a + Number(r.monto_total), 0);

    // Cuando NO se filtra por moneda, exponer totales separados por moneda para que el frontend
    // pueda advertir al usuario si hay mezcla (rara en la práctica pero posible en empresas multi-moneda).
    const porMonedaMap = new Map<string, number>();
    for (const r of recibos) {
      const code = r.moneda_codigo || 'PYG';
      porMonedaMap.set(code, (porMonedaMap.get(code) || 0) + Number(r.monto_total));
    }
    const porMonedaTotales = [...porMonedaMap.entries()]
      .map(([codigo, monto]) => ({ codigo, monto }))
      .sort((a, b) => b.monto - a.monto);

    return {
      recibos: recibos.map((r) => ({
        recibo_id: r.recibo_id,
        numero_recibo: r.numero_recibo,
        fecha: new Date(r.fecha).toISOString().slice(0, 10),
        cliente_razon: r.cliente_razon,
        cobrador_nombre: r.cobrador_nombre,
        usuario_nombre: r.usuario_nombre,
        modo: r.modo,
        monto_total: Number(r.monto_total),
        moneda_codigo: r.moneda_codigo || 'PYG',
      })),
      porDia,
      porCondicion,
      porMedio,
      porMonedaTotales,
      totalGeneral,
      mediosBuckets: MEDIOS_BUCKETS,
      filtros: filters,
    };
  }

  /** PDF del cierre de cobros vía msv-kude (reporte-tabla genérico). */
  async exportarCierreCobrosPDF(
    empresaId: string,
    filters: { fechaDesde?: string; fechaHasta?: string; cobradorId?: string; sucursalId?: string; monedaId?: string },
  ): Promise<Buffer> {
    const data = await this.getCierreCobros(empresaId, filters);
    // Determinar moneda para el PDF: si se filtró por una moneda específica, usar esa; sino la más común
    let monedaCodigo = 'PYG';
    if (data.porMonedaTotales?.length) {
      monedaCodigo = data.porMonedaTotales[0].codigo;
    }
    const empresa: any = await this.prisma.empresas.findUnique({ where: { id: empresaId } });

    // Sólo incluir columnas de medio que efectivamente tienen monto (para no desperdiciar espacio en A4 portrait)
    const mesesBuckets: string[] = data.mediosBuckets.filter((b: string) => {
      const total = data.porMedio.find((p: { medio: string; monto: number }) => p.medio === b)?.monto ?? 0;
      return total > 0;
    });

    // Tabla principal: filas por día
    const columns: any[] = [
      { key: 'fecha', label: 'Fecha', type: 'date', weight: 1.1 },
      { key: 'recibos', label: 'Recibos', type: 'number', weight: 0.6 },
    ];
    for (const b of mesesBuckets) {
      columns.push({ key: `medio_${b}`, label: b, type: 'currency', weight: 1.1 });
    }
    columns.push({ key: 'total_cobrado', label: 'Total', type: 'currency', weight: 1.3 });

    const rows: any[] = data.porDia.map(
      (d: { fecha: string; recibos: number; total_cobrado: number; por_medio: Record<string, number> }) => {
        const row: any = { fecha: d.fecha, recibos: d.recibos, total_cobrado: d.total_cobrado };
        for (const b of mesesBuckets) row[`medio_${b}`] = d.por_medio[b] ?? 0;
        return row;
      },
    );

    // Fila TOTAL al pie de la tabla principal
    const totalRow: any = { fecha: 'TOTAL', recibos: data.recibos.length, total_cobrado: data.totalGeneral };
    for (const b of mesesBuckets) {
      totalRow[`medio_${b}`] = data.porMedio.find((p: { medio: string; monto: number }) => p.medio === b)?.monto ?? 0;
    }
    rows.push(totalRow);

    // Sección "Por condición de pago" como tabla independiente debajo de la principal
    const secciones = [
      {
        titulo: 'Por condición de pago',
        columns: [
          { key: 'condicion', label: 'Condición', weight: 2 },
          { key: 'cantidad_recibos', label: 'Recibos', type: 'number', weight: 0.8 },
          { key: 'cantidad_lineas', label: 'Líneas', type: 'number', weight: 0.8 },
          { key: 'monto', label: 'Monto', type: 'currency', weight: 1.4 },
        ],
        rows: data.porCondicion,
      },
    ];

    const totales = [
      { label: 'Total cobrado', value: data.totalGeneral, type: 'currency' },
      { label: 'Recibos', value: data.recibos.length, type: 'number' },
    ];

    const payload: Record<string, unknown> = {
      titulo: 'Cierre de cobros',
      empresa: {
        razon_social: empresa?.razon_social ?? empresa?.nombre_fantasia ?? '',
        // Nombre comercial arriba y razón social debajo en el PDF (criterio de la factura).
        nombre_fantasia: empresa?.nombre_fantasia ?? null,
        ruc: empresa?.ruc ?? '',
        dv: empresa?.dv ?? '0',
        direccion: empresa?.direccion ?? '',
        telefono: empresa?.telefono ?? '',
        email: empresa?.email ?? '',
        logo_url: empresa?.logo_url ?? empresa?.logo ?? '',
      },
      desde: filters.fechaDesde ?? null,
      hasta: filters.fechaHasta ?? null,
      generado_en: new Date(),
      moneda: monedaCodigo,
      columns,
      rows,
      totales,
      secciones,
      pageSize: 'A4',
      orientation: 'portrait',
      tipo: 'view',
    };

    const url = `${envs.apiGeneradorPDF}/api/reporte-tabla/generate-pdf`;
    const response = await fetch(url, {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify(payload),
    });
    if (!response.ok) {
      const errText = await response.text();
      this.logger.error(`msv-kude reporte-tabla ${response.status}: ${errText}`);
      throw new HttpException({ message: 'Error al generar PDF', detail: errText }, HttpStatus.BAD_GATEWAY);
    }
    const ab = await response.arrayBuffer();
    return Buffer.from(ab);
  }

  // ==================== REVISIÓN CxC ====================

  async getCuentasCobrarByCliente(clienteId: string, empresa_id: string) {
    const fmtDate = (d: Date | null | undefined): string | null => {
      if (!d) return null;
      return d instanceof Date ? d.toISOString().split('T')[0] : String(d).split('T')[0];
    };

    const rows = await this.prisma.cuentas_cobrar.findMany({
      where: { cliente_id: clienteId, empresa_id },
      include: {
        factura_cab: {
          select: {
            id: true,
            dnumdoc: true,
            dest: true,
            dpunexp: true,
            dcuotas: true,
            total_factura: true,
            moneda: { select: { codigo: true } },
            factura_det: {
              take: 1,
              select: { ddesproser: true, dcantproser: true, duniproser: true },
            },
          },
        },
        factura_cuotas: { orderBy: { nro_cuota: 'asc' } },
      },
      orderBy: { created_at: 'asc' },
    });

    // Interés manual pendiente: por cuota y por cuenta, para que la pantalla lo
    // muestre y lo edite.
    const interesManual = await this.interesManual.pendienteParaCobro(
      empresa_id,
      rows.flatMap((c) => c.factura_cuotas.map((q) => q.id)),
      rows.map((c) => c.factura_venta_id).filter((id): id is string => !!id),
    );

    return rows.map((c) => ({
      ...c,
      fecha_emision: fmtDate(c.fecha_emision),
      fecha_vencimiento: fmtDate(c.fecha_vencimiento),
      interes_manual_cuenta: c.factura_venta_id ? (interesManual.porCuenta.get(c.factura_venta_id) ?? 0) : 0,
      factura_cuotas: c.factura_cuotas.map((q) => ({
        ...q,
        dvenccuo: fmtDate(q.dvenccuo),
        interes_manual: interesManual.porCuota.get(q.id) ?? 0,
      })),
    }));
  }

  async updateCuentaCobrar(
    cuentaId: string,
    empresaId: string,
    saldoPendiente: number,
    marcarRevisado = false,
    userId?: string,
    motivo?: string,
  ) {
    // empresa_id puede ser null en cuentas migradas — buscar solo por id
    const cuenta = await this.prisma.cuentas_cobrar.findFirst({
      where: { id: cuentaId },
      include: {
        clientes: { select: { id: true, personas: { select: { razon_social: true, ruc: true } } } },
        factura_cab: { select: { id: true, dnumdoc: true, dest: true, dpunexp: true } },
      },
    });
    if (!cuenta) throw new Error('Cuenta a cobrar no encontrada');

    const saldoAnterior = Number(cuenta.saldo_pendiente);
    const diferencia = saldoPendiente - saldoAnterior;
    const estadoAnterior = cuenta.estado;
    const revisadoAnterior = cuenta.revisado;

    const updated = await this.prisma.cuentas_cobrar.update({
      where: { id: cuentaId },
      data: {
        saldo_pendiente: saldoPendiente,
        estado: saldoPendiente <= 0 ? 'pagada' : cuenta.estado,
        ...(marcarRevisado ? { revisado: true, revisado_en: new Date() } : {}),
        updated_at: new Date(),
      },
    });

    if (diferencia !== 0) {
      await this.prisma.clientes.update({
        where: { id: cuenta.cliente_id },
        data: {
          saldo_pendiente: { increment: diferencia },
          updated_at: new Date(),
        },
      });
    }

    const facturaRef = cuenta.factura_cab
      ? `${String(cuenta.factura_cab.dest).padStart(3, '0')}-${String(cuenta.factura_cab.dpunexp).padStart(3, '0')}-${String(cuenta.factura_cab.dnumdoc).padStart(7, '0')}`
      : null;
    const clienteNombre = cuenta.clientes?.personas?.razon_social ?? null;

    await this.auditService.log({
      empresa_id: empresaId ?? (cuenta.empresa_id ? String(cuenta.empresa_id) : undefined),
      user_id: userId,
      action: 'UPDATE',
      entity_type: 'cuenta_cobrar',
      entity_id: cuentaId,
      descripcion:
        `Revisión CxC: saldo ajustado de ${saldoAnterior} a ${saldoPendiente} (Δ ${diferencia})` +
        (facturaRef ? ` | Factura ${facturaRef}` : '') +
        (clienteNombre ? ` | Cliente: ${clienteNombre}` : '') +
        (marcarRevisado ? ' | marcada como revisada' : '') +
        (motivo ? ` | Motivo: ${motivo}` : ''),
      old_value: {
        saldo_pendiente: saldoAnterior,
        estado: estadoAnterior,
        revisado: revisadoAnterior,
        revisado_en: cuenta.revisado_en ?? null,
      },
      new_value: {
        saldo_pendiente: Number(updated.saldo_pendiente),
        estado: updated.estado,
        revisado: updated.revisado,
        revisado_en: updated.revisado_en,
        parametros: {
          diferencia,
          cliente_id: cuenta.cliente_id,
          factura_cab_id: cuenta.factura_venta_id,
          factura_ref: facturaRef,
          motivo: motivo ?? null,
        },
      },
    });

    return updated;
  }

  async getCondicionesPago(empresaId: string) {
    return this.prisma.condiciones_pago.findMany({
      where: { empresa_id: empresaId, activo: true },
      select: { id: true, descripcion: true, cuotas: true, intervalo_dias: true, dias_plazo: true },
      orderBy: { descripcion: 'asc' },
    });
  }

  async marcarComoPagada(cuentaId: string, empresaId?: string, userId?: string, motivo?: string) {
    const cuenta = await this.prisma.cuentas_cobrar.findFirst({
      where: { id: cuentaId },
      include: {
        factura_cuotas: {
          // El ERP escribe 'Pendiente' (EstadoFactura.PENDIENTE) y los flujos de cobro
          // e importación escriben 'pendiente'. Filtrar solo por la minúscula dejaba
          // cuotas vivas en una cuenta marcada como pagada.
          where: { estado: { in: ['pendiente', 'Pendiente'] } },
          select: { id: true, nro_cuota: true, saldo_pendiente: true, dmoncuota: true },
        },
        clientes: { select: { id: true, personas: { select: { razon_social: true } } } },
        factura_cab: { select: { id: true, dnumdoc: true, dest: true, dpunexp: true } },
      },
    });
    if (!cuenta) throw new Error('Cuenta a cobrar no encontrada');

    const saldoActual = Number(cuenta.saldo_pendiente);
    const estadoAnterior = cuenta.estado;
    const cuotasAfectadas = cuenta.factura_cuotas.map((c) => ({
      id: c.id,
      nro_cuota: c.nro_cuota,
      saldo_pendiente: Number(c.saldo_pendiente),
      dmoncuota: Number(c.dmoncuota),
    }));

    await this.prisma.$transaction(async (tx) => {
      // Marcar todas las cuotas pendientes como pagadas con saldo 0
      if (cuenta.factura_cuotas.length > 0) {
        await tx.factura_cuotas.updateMany({
          where: { id: { in: cuenta.factura_cuotas.map((c) => c.id) } },
          data: { estado: 'pagado', saldo_pendiente: 0, updated_at: new Date() },
        });
      }
      // Marcar la cuenta como pagada
      await tx.cuentas_cobrar.update({
        where: { id: cuentaId },
        data: { estado: 'pagada', saldo_pendiente: 0, updated_at: new Date() },
      });
      // Decrementar saldo del cliente
      if (saldoActual > 0 && cuenta.cliente_id) {
        await tx.clientes.update({
          where: { id: cuenta.cliente_id },
          data: { saldo_pendiente: { decrement: saldoActual }, updated_at: new Date() },
        });
      }
    });

    const facturaRef = cuenta.factura_cab
      ? `${String(cuenta.factura_cab.dest).padStart(3, '0')}-${String(cuenta.factura_cab.dpunexp).padStart(3, '0')}-${String(cuenta.factura_cab.dnumdoc).padStart(7, '0')}`
      : null;
    const clienteNombre = cuenta.clientes?.personas?.razon_social ?? null;

    await this.auditService.log({
      empresa_id: empresaId ?? (cuenta.empresa_id ? String(cuenta.empresa_id) : undefined),
      user_id: userId,
      action: 'MARK_AS_PAID',
      entity_type: 'cuenta_cobrar',
      entity_id: cuentaId,
      descripcion:
        `Revisión CxC: cuenta marcada como pagada manualmente | Saldo cancelado: ${saldoActual}` +
        ` | Cuotas afectadas: ${cuotasAfectadas.length}` +
        (facturaRef ? ` | Factura ${facturaRef}` : '') +
        (clienteNombre ? ` | Cliente: ${clienteNombre}` : '') +
        (motivo ? ` | Motivo: ${motivo}` : ''),
      old_value: {
        estado: estadoAnterior,
        saldo_pendiente: saldoActual,
        cuotas_pendientes_count: cuotasAfectadas.length,
        cuotas_pendientes: cuotasAfectadas,
      },
      new_value: {
        estado: 'pagada',
        saldo_pendiente: 0,
        cuotas_pendientes_count: 0,
        cuotas_pendientes: [],
        parametros: {
          saldo_cancelado: saldoActual,
          cuotas_marcadas_pagadas: cuotasAfectadas.length,
          cliente_id: cuenta.cliente_id,
          factura_cab_id: cuenta.factura_venta_id,
          factura_ref: facturaRef,
          motivo: motivo ?? null,
        },
      },
    });

    return { ok: true, saldoCancelado: saldoActual };
  }

  async regenerarCuotas(
    cuentaId: string,
    empresaId: string,
    dto: {
      monto_cuota: number;
      frecuencia: 'semanal' | 'quincenal' | 'mensual';
      fecha_primer_vencimiento: string;
      forzar?: boolean;
      motivo?: string;
    },
    userId?: string,
  ) {
    // empresa_id puede ser null en cuentas migradas — buscar solo por id
    const cuenta = await this.prisma.cuentas_cobrar.findFirst({
      where: { id: cuentaId },
      include: {
        factura_cuotas: {
          orderBy: { nro_cuota: 'asc' },
          include: {
            detalle_pago: { select: { id: true } },
            recibo_cobro_detalle: { select: { id: true } },
          },
        },
      },
    });
    if (!cuenta) throw new Error('Cuenta a cobrar no encontrada');

    // Comparación case-insensitive: 'pendiente' | 'Pendiente'
    const esPendiente = (e: string | null) => e?.toLowerCase() === 'pendiente';

    const tienePagos = (c: (typeof cuenta.factura_cuotas)[0]) =>
      c.detalle_pago.length > 0 || c.recibo_cobro_detalle.length > 0;

    // forzar=true: eliminar TODAS las cuotas pendientes (incluso migradas con registros de pago)
    const cuotasPendientes = cuenta.factura_cuotas.filter((c) => esPendiente(c.estado));
    const cuotasPagadas = dto.forzar
      ? cuenta.factura_cuotas.filter((c) => !esPendiente(c.estado))
      : cuenta.factura_cuotas.filter((c) => !esPendiente(c.estado) || tienePagos(c));
    const cuotasPendientesIds = dto.forzar
      ? cuotasPendientes.map((c) => c.id)
      : cuotasPendientes.filter((c) => !tienePagos(c)).map((c) => c.id);

    // factura_cab_id requerido — determinar ANTES de eliminar cuotas
    const facturaCabId = cuenta.factura_venta_id ?? cuenta.factura_cuotas[0]?.factura_cab_id ?? null;

    if (!facturaCabId) throw new Error('No se puede determinar la factura asociada a esta cuenta');

    const saldo = Number(cuenta.saldo_pendiente);
    const monto = dto.monto_cuota;
    const nroCuotaBase = cuotasPagadas.length;

    const intervalMap = { semanal: 7, quincenal: 15, mensual: 30 };
    const intervalo = intervalMap[dto.frecuencia];

    const primerVenc = new Date(dto.fecha_primer_vencimiento);

    // Construir política respetando lo guardado en la cuenta (fuente de verdad
    // del acuerdo comercial). La frecuencia del DTO override el intervalo si el
    // usuario la cambió en la UI de regeneración.
    const cuentaAny = cuenta as unknown as {
      dia_fijo_pago_1?: number | null;
      dia_fijo_pago_2?: number | null;
      dia_cobro_semana?: number | null;
    };
    const politica: PoliticaCobro = {
      intervalo_dias: intervalo,
      dia_fijo_pago_1: cuentaAny.dia_fijo_pago_1 ?? null,
      dia_fijo_pago_2: cuentaAny.dia_fijo_pago_2 ?? null,
      dia_cobro_semana: cuentaAny.dia_cobro_semana ?? null,
    };

    // Generar cronograma central respetando política + primer vencimiento manual
    const cronograma = calcularCronograma(politica, {
      saldoFinanciar: saldo,
      montoCuota: monto,
      fechaBase: primerVenc,
      fechaPrimeraCuotaHint: primerVenc,
    });

    const nuevasCuotas = cronograma.map((c) => ({
      factura_cab_id: facturaCabId,
      cuenta_id: cuentaId,
      cmonecuo: 'PYG',
      nro_cuota: nroCuotaBase + c.nro_cuota,
      dmoncuota: c.dmoncuota,
      saldo_pendiente: c.dmoncuota,
      dvenccuo: c.dvenccuo,
      estado: 'pendiente',
    }));

    // Métricas para auditoría — el residuo ya fue absorbido en la última cuota por
    // calcularCronograma, por eso queda en 0.
    const residuo = 0;

    const ultimaFecha = nuevasCuotas.length > 0 ? nuevasCuotas[nuevasCuotas.length - 1].dvenccuo : primerVenc;

    // Ejecutar en transacción: delete + create + update cuenta (atómico)
    await this.prisma.$transaction(async (tx) => {
      if (dto.forzar && cuotasPendientesIds.length > 0) {
        await tx.detalle_pago.deleteMany({ where: { factura_cuota_id: { in: cuotasPendientesIds } } });
        await tx.recibo_cobro_detalle.deleteMany({ where: { factura_cuota_id: { in: cuotasPendientesIds } } });
      }
      if (cuotasPendientesIds.length > 0) {
        // Los intereses cargados sobre esas cuotas pasan a la cuenta: si no, la
        // cuota no se puede borrar (queda referenciada) y el cargo perdería
        // sentido al desaparecer su cuota.
        await this.interesManual.liberarDeCuotas(tx, empresaId, cuotasPendientesIds);
        await tx.factura_cuotas.deleteMany({ where: { id: { in: cuotasPendientesIds } } });
      }
      if (nuevasCuotas.length > 0) {
        await tx.factura_cuotas.createMany({ data: nuevasCuotas });
      }
      await tx.cuentas_cobrar.update({
        where: { id: cuentaId },
        data: { fecha_vencimiento: ultimaFecha, updated_at: new Date() },
      });
    });

    // No-crítico: actualizar dcuotas en factura_cab (no afecta las cuotas si falla)
    if (cuenta.factura_venta_id) {
      const totalCuotas = cuotasPagadas.length + nuevasCuotas.length;
      await this.prisma.factura_cab
        .update({ where: { id: cuenta.factura_venta_id }, data: { dcuotas: totalCuotas } })
        .catch((e) => this.logger.warn(`No se pudo actualizar dcuotas: ${e.message}`));
    }

    // Auditoría — operación delicada: registra estado completo antes/después
    const facturaInfo = cuenta.factura_venta_id
      ? await this.prisma.factura_cab.findUnique({
          where: { id: cuenta.factura_venta_id },
          select: { dnumdoc: true, dest: true, dpunexp: true, cliente_id: true },
        })
      : null;
    const facturaRef = facturaInfo
      ? `${String(facturaInfo.dest).padStart(3, '0')}-${String(facturaInfo.dpunexp).padStart(3, '0')}-${String(facturaInfo.dnumdoc).padStart(7, '0')}`
      : null;

    const cuotasEliminadasResumen = cuenta.factura_cuotas
      .filter((c) => cuotasPendientesIds.includes(c.id))
      .map((c) => ({
        id: c.id,
        nro_cuota: c.nro_cuota,
        dmoncuota: Number(c.dmoncuota),
        saldo_pendiente: Number(c.saldo_pendiente),
        estado: c.estado,
        dvenccuo: c.dvenccuo,
        tenia_pagos: c.detalle_pago.length > 0 || c.recibo_cobro_detalle.length > 0,
      }));

    await this.auditService.log({
      empresa_id: empresaId ?? (cuenta.empresa_id ? String(cuenta.empresa_id) : undefined),
      user_id: userId,
      action: dto.forzar ? 'REGENERATE_FORCED' : 'REGENERATE',
      entity_type: 'cuenta_cobrar',
      entity_id: cuentaId,
      descripcion:
        `Revisión CxC: cuotas regeneradas${dto.forzar ? ' (FORZADO)' : ''} | ` +
        `Saldo a redistribuir: ${saldo} | Monto/cuota: ${dto.monto_cuota} | Frecuencia: ${dto.frecuencia}` +
        ` | Eliminadas: ${cuotasPendientesIds.length} | Creadas: ${nuevasCuotas.length}` +
        (facturaRef ? ` | Factura ${facturaRef}` : '') +
        (dto.motivo ? ` | Motivo: ${dto.motivo}` : ''),
      old_value: {
        saldo_pendiente: saldo,
        total_cuotas: cuenta.factura_cuotas.length,
        cuotas_pendientes: cuotasPendientes.length,
        cuotas_pagadas: cuotasPagadas.length,
        ultima_fecha_vencimiento: cuenta.fecha_vencimiento,
        cuotas_eliminadas: cuotasEliminadasResumen,
      },
      new_value: {
        saldo_pendiente: saldo,
        total_cuotas: cuotasPagadas.length + nuevasCuotas.length,
        cuotas_pendientes: nuevasCuotas.length,
        cuotas_pagadas: cuotasPagadas.length,
        ultima_fecha_vencimiento: ultimaFecha,
        cuotas_creadas: nuevasCuotas.map((c) => ({
          nro_cuota: c.nro_cuota,
          dmoncuota: c.dmoncuota,
          dvenccuo: c.dvenccuo,
        })),
        parametros: {
          monto_cuota: dto.monto_cuota,
          frecuencia: dto.frecuencia,
          fecha_primer_vencimiento: dto.fecha_primer_vencimiento,
          forzar: !!dto.forzar,
          residuo,
          factura_cab_id: cuenta.factura_venta_id,
          factura_ref: facturaRef,
          cliente_id: cuenta.cliente_id,
          motivo: dto.motivo ?? null,
        },
      },
    });

    return this.prisma.factura_cuotas.findMany({
      where: { cuenta_id: cuentaId },
      orderBy: { nro_cuota: 'asc' },
    });
  }

  // ==================== GENERACIÓN DE PDF ====================

  /**
   * Genera el PDF del recibo llamando a msv-kude v2.
   * Arma el JSON normalizado a partir de los datos en BD.
   */
  async generateReciboPdf(
    reciboId: string,
    empresa_id: string,
    options: { formato?: string; tipo_retorno?: string; ancho_papel?: number } = {},
  ): Promise<{ pdfBuffer?: Buffer; base64?: string; contentType: string }> {
    const { formato = 'a4', tipo_retorno = 'view', ancho_papel = 80 } = options;

    // 1. Obtener recibo con todos los includes
    const recibo = await this.prisma.recibos_cobro.findFirst({
      where: { id: reciboId, empresa_id },
      include: {
        empresas: true,
        empresas_sucursales: true,
        clientes: {
          include: {
            personas: true,
          },
        },
        usuario: {
          select: { id: true, nombres: true, apellidos: true },
        },
        cobrador: {
          select: { id: true, nombre: true, apellido: true },
        },
        moneda: true,
        recibo_cobro_detalle: {
          include: {
            factura_cuotas: {
              include: {
                factura_cab: {
                  select: {
                    id: true,
                    dnumdoc: true,
                    dest: true,
                    dpunexp: true,
                    dfeemide: true,
                    dcuotas: true,
                  },
                },
              },
            },
            medio_pago: true,
            tarjeta: true,
            forma_procesamiento_pago: true,
            bancos: true,
            moneda: true,
          },
        },
      },
    });

    if (!recibo) {
      throw new NotFoundException(`Recibo de cobro con ID ${reciboId} no encontrado`);
    }

    const empresa = recibo.empresas;
    const persona = recibo.clientes?.personas;
    const moneda = recibo.moneda;

    // 2. Determinar tipo de documento del cliente (RUC o CI)
    let tipoDocumento = 'RUC';
    let documentoCliente = '';
    if (persona?.ruc && persona?.dv) {
      documentoCliente = `${persona.ruc}-${persona.dv}`;
      tipoDocumento = 'RUC';
    } else if (persona?.nro_documento) {
      documentoCliente = persona.nro_documento;
      tipoDocumento = 'CI';
    }

    // 3a. Calcular saldo pendiente actual por factura (suma de cuotas pendientes)
    //     para mostrarlo junto a cada cuota cobrada en el ticket.
    const facturaIds = [
      ...new Set(
        recibo.recibo_cobro_detalle.map((d) => d.factura_cuotas?.factura_cab?.id).filter((id): id is string => !!id),
      ),
    ];
    const saldosPorFactura = new Map<string, number>();
    if (facturaIds.length > 0) {
      const rows = await this.prisma.$queryRaw<{ factura_cab_id: string; saldo: number }[]>`
        SELECT fcu.factura_cab_id, COALESCE(SUM(
          CASE
            WHEN LOWER(fcu.estado) IN ('pagado','anulado','anulada') THEN 0
            ELSE COALESCE(NULLIF(fcu.saldo_pendiente, 0), fcu.dmoncuota, 0)
          END
        ), 0)::float AS saldo
        FROM factura_cuotas fcu
        WHERE fcu.factura_cab_id IN (${Prisma.join(facturaIds.map((id) => Prisma.sql`${id}::uuid`))})
        GROUP BY fcu.factura_cab_id
      `;
      for (const r of rows) saldosPorFactura.set(r.factura_cab_id, Number(r.saldo || 0));
    }

    // 3a-bis. Cargar items (factura_det) de cada factura involucrada para imprimirlos en el ticket.
    const itemsPorFactura = new Map<string, { descripcion: string; cantidad: number; subtotal: number }[]>();
    if (facturaIds.length > 0) {
      const dets = await this.prisma.factura_det.findMany({
        where: { factura_cab_id: { in: facturaIds } },
        select: {
          factura_cab_id: true,
          ddesproser: true,
          dcantproser: true,
          dtotbruopeitem: true,
        },
        orderBy: { id: 'asc' },
      });
      for (const d of dets) {
        const key = d.factura_cab_id;
        if (!itemsPorFactura.has(key)) itemsPorFactura.set(key, []);
        itemsPorFactura.get(key)!.push({
          descripcion: d.ddesproser || '-',
          cantidad: Number(d.dcantproser || 0),
          subtotal: Number(d.dtotbruopeitem || 0),
        });
      }
    }

    // 3. Armar detalles normalizados
    const detalles = recibo.recibo_cobro_detalle.map((det) => {
      const cuota = det.factura_cuotas;
      const facturaCab = cuota?.factura_cab;
      const facturaNumero = facturaCab ? `${facturaCab.dest}-${facturaCab.dpunexp}-${facturaCab.dnumdoc}` : '-';

      return {
        factura_numero: facturaNumero,
        nro_cuota: cuota?.nro_cuota || null,
        total_cuotas: facturaCab?.dcuotas ?? null,
        fecha_vencimiento: cuota?.dvenccuo ? new Date(cuota.dvenccuo).toISOString() : null,
        medio_pago: det.medio_pago?.descripcion || '-',
        monto_original: Number(det.monto_original || 0),
        monto_pagado: Number(det.monto_pagado || 0),
        descuento_aplicado: Number(det.descuento_aplicado || 0),
        // Interés cobrado con esta cuota (mora + interés manual). El ticket
        // ESC/POS y el PDF lo imprimen como línea propia bajo el monto.
        mora_monto: Number(det.mora_monto || 0),
        es_pago_parcial: det.es_pago_parcial || false,
        saldo_factura: facturaCab?.id ? (saldosPorFactura.get(facturaCab.id) ?? null) : null,
        items_factura: facturaCab?.id ? (itemsPorFactura.get(facturaCab.id) ?? null) : null,
        banco: det.bancos?.descripcion || null,
        numero_cheque: det.numero_cheque || null,
        numero_tarjeta: det.numero_tarjeta || null,
        codigo_autorizacion: det.codigo_autorizacion || null,
        titular_tarjeta: det.titular_tarjeta || null,
        razon_social_procesadora: det.razon_social_procesadora || null,
      };
    });

    // 4. Agrupar formas de pago
    const formasPagoMap = new Map<
      string,
      {
        descripcion: string;
        monto: number;
        banco: string | null;
        numero_cheque: string | null;
        numero_tarjeta: string | null;
        codigo_autorizacion: string | null;
      }
    >();

    for (const det of recibo.recibo_cobro_detalle) {
      const medioPagoId = det.medio_pago_id;
      const medioPagoDesc = det.medio_pago?.descripcion || 'Otro';

      if (!formasPagoMap.has(medioPagoId)) {
        formasPagoMap.set(medioPagoId, {
          descripcion: medioPagoDesc,
          monto: 0,
          banco: det.bancos?.descripcion || null,
          numero_cheque: det.numero_cheque || null,
          numero_tarjeta: det.numero_tarjeta || null,
          codigo_autorizacion: det.codigo_autorizacion || null,
        });
      }

      const fp = formasPagoMap.get(medioPagoId);
      fp.monto += Number(det.monto_pagado || 0);
    }

    const formasPago = Array.from(formasPagoMap.values());

    // 4b. Calcular saldo de cuenta del cliente (sumatoria de cuotas pendientes
    //     de TODAS sus facturas, ya descontado el cobro actual porque las cuotas
    //     pagadas se actualizan en la misma transacción del recibo).
    let saldoCuentaCliente = 0;
    if (recibo.cliente_id) {
      const saldoRow = await this.prisma.$queryRaw<{ saldo: number | null }[]>`
        SELECT COALESCE(SUM(
          CASE
            WHEN LOWER(fcu.estado) IN ('pagado','anulado','anulada') THEN 0
            ELSE COALESCE(NULLIF(fcu.saldo_pendiente, 0), fcu.dmoncuota, 0)
          END
        ), 0)::float AS saldo
        FROM factura_cuotas fcu
        JOIN factura_cab fc ON fc.id = fcu.factura_cab_id
        WHERE fc.cliente_id = ${recibo.cliente_id}::uuid
          AND fc.empresa_id = ${recibo.empresa_id}::uuid
      `;
      saldoCuentaCliente = Number(saldoRow?.[0]?.saldo || 0);
    }

    // 5. Armar JSON normalizado para msv-kude
    const payload = {
      formato,
      tipo: tipo_retorno,
      ancho_papel,
      empresa: {
        razon_social: empresa.razon_social || '',
        ruc: empresa.ruc,
        dv: empresa.dv,
        direccion: '', // empresas no tiene dirección directa
        telefono: empresa.celular || '',
        email: empresa.email || '',
        logo_url: logoEmisor(recibo.empresas_sucursales, empresa),
        nombre_fantasia: nombreFantasiaEmisor(recibo.empresas_sucursales, empresa),
      },
      sucursal: recibo.empresas_sucursales
        ? {
            nombre: recibo.empresas_sucursales.descripcion || '',
            direccion: recibo.empresas_sucursales.direccion || '',
            telefono: recibo.empresas_sucursales.telefono || '',
          }
        : null,
      cobrador: recibo.cobrador
        ? {
            nombre: [recibo.cobrador.nombre, recibo.cobrador.apellido].filter(Boolean).join(' '),
          }
        : null,
      numero_recibo: recibo.numero_recibo,
      fecha_emision: recibo.fecha_emision?.toISOString(),
      estado: recibo.estado,
      moneda: moneda
        ? {
            codigo: moneda.codigo,
            simbolo: moneda.simbolo,
            decimales: moneda.decimales,
          }
        : { codigo: 'PYG', simbolo: '₲', decimales: 0 },
      monto_total: Number(recibo.monto_total),
      monto_recibido: Number(recibo.monto_recibido || recibo.monto_total),
      descuento_monto: Number(recibo.descuento_monto || 0),
      descuento_porcentaje: recibo.descuento_porcentaje ? Number(recibo.descuento_porcentaje) : null,
      motivo_descuento: recibo.motivo_descuento || null,
      cotizacion: recibo.cotizacion ? Number(recibo.cotizacion) : null,
      mora_total: Number(recibo.mora_total ?? 0),
      concepto: 'Pago de facturas crédito',
      observacion: recibo.observacion || null,
      cliente: {
        razon_social: persona?.razon_social || '',
        documento: documentoCliente,
        tipo_documento: tipoDocumento,
        direccion: persona?.direccion || '',
        telefono: persona?.telefono || persona?.celular || '',
        email: persona?.email || '',
      },
      detalles,
      formas_pago: formasPago,
      saldo_cuenta: saldoCuentaCliente,
    };
    console.log(JSON.stringify(payload));

    // 6. Llamar a msv-kude
    const url = `${envs.apiGeneradorPDF}/api/v2/generate-recibo`;

    try {
      const response = await fetch(url, {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify(payload),
      });

      if (!response.ok) {
        const errorBody = await response.text();
        throw new BadRequestException(`Error al generar PDF del recibo: ${errorBody}`);
      }

      if (tipo_retorno === 'base64') {
        const json = (await response.json()) as {
          status: string;
          pdf_base64?: string;
          message?: string;
        };
        if (json.status !== 'success') {
          throw new BadRequestException(json.message || 'Error al generar recibo');
        }
        return { base64: json.pdf_base64, contentType: 'application/json' };
      }

      // view → binary PDF
      const arrayBuffer = await response.arrayBuffer();
      const pdfBuffer = Buffer.from(arrayBuffer);
      return { pdfBuffer, contentType: 'application/pdf' };
    } catch (error) {
      if (error instanceof BadRequestException) throw error;
      const message = error instanceof Error ? error.message : 'Error desconocido';
      throw new BadRequestException(`No se pudo generar el PDF del recibo: ${message}`);
    }
  }

  // ==================== PRINT DATA ====================

  async getReciboPrintData(id: string, empresa_id: string, options: { paperWidth?: number } = {}) {
    const { paperWidth = 80 } = options;
    // Mismo payload que se envía a msv-kude para el ticket de la web.
    // De ese modo el ESC/POS de la app móvil y el PDF de la web comparten estructura
    // (cobrador, sucursal, formas de pago con banco/cheque, items por factura, saldo factura, etc.).
    const payload = await this.buildReciboPayload(id, empresa_id, {
      ancho_papel: paperWidth,
    });
    return { ...payload, paper_width: paperWidth };
  }

  /**
   * Arma el payload completo de un recibo (mismo shape que consume `msv-kude/recibos/v2/recibo_ticket.js`).
   * Usado por `generateReciboPdf` (web → PDF) y `getReciboPrintData` (móvil → ESC/POS).
   */
  private async buildReciboPayload(
    reciboId: string,
    empresa_id: string,
    options: { formato?: string; tipo_retorno?: string; ancho_papel?: number } = {},
  ) {
    const { formato = 'ticket', tipo_retorno = 'view', ancho_papel = 80 } = options;

    const recibo = await this.prisma.recibos_cobro.findFirst({
      where: { id: reciboId, empresa_id },
      include: {
        empresas: true,
        empresas_sucursales: true,
        clientes: { include: { personas: true } },
        usuario: { select: { id: true, nombres: true, apellidos: true } },
        cobrador: { select: { id: true, nombre: true, apellido: true } },
        moneda: true,
        recibo_cobro_detalle: {
          include: {
            factura_cuotas: {
              include: {
                factura_cab: {
                  select: {
                    id: true,
                    dnumdoc: true,
                    dest: true,
                    dpunexp: true,
                    dfeemide: true,
                    dcuotas: true,
                  },
                },
              },
            },
            medio_pago: true,
            tarjeta: true,
            forma_procesamiento_pago: true,
            bancos: true,
            moneda: true,
          },
        },
      },
    });

    if (!recibo) {
      throw new NotFoundException(`Recibo de cobro con ID ${reciboId} no encontrado`);
    }

    const empresa = recibo.empresas;
    const persona = recibo.clientes?.personas;
    const moneda = recibo.moneda;

    let tipoDocumento = 'RUC';
    let documentoCliente = '';
    if (persona?.ruc && persona?.dv) {
      documentoCliente = `${persona.ruc}-${persona.dv}`;
      tipoDocumento = 'RUC';
    } else if (persona?.nro_documento) {
      documentoCliente = persona.nro_documento;
      tipoDocumento = 'CI';
    }

    const facturaIds = [
      ...new Set(
        recibo.recibo_cobro_detalle.map((d) => d.factura_cuotas?.factura_cab?.id).filter((id): id is string => !!id),
      ),
    ];

    const saldosPorFactura = new Map<string, number>();
    if (facturaIds.length > 0) {
      const rows = await this.prisma.$queryRaw<{ factura_cab_id: string; saldo: number }[]>`
        SELECT fcu.factura_cab_id, COALESCE(SUM(
          CASE
            WHEN LOWER(fcu.estado) IN ('pagado','anulado','anulada') THEN 0
            ELSE COALESCE(NULLIF(fcu.saldo_pendiente, 0), fcu.dmoncuota, 0)
          END
        ), 0)::float AS saldo
        FROM factura_cuotas fcu
        WHERE fcu.factura_cab_id IN (${Prisma.join(facturaIds.map((id) => Prisma.sql`${id}::uuid`))})
        GROUP BY fcu.factura_cab_id
      `;
      for (const r of rows) saldosPorFactura.set(r.factura_cab_id, Number(r.saldo || 0));
    }

    const itemsPorFactura = new Map<string, { descripcion: string; cantidad: number; subtotal: number }[]>();
    if (facturaIds.length > 0) {
      const dets = await this.prisma.factura_det.findMany({
        where: { factura_cab_id: { in: facturaIds } },
        select: {
          factura_cab_id: true,
          ddesproser: true,
          dcantproser: true,
          dtotbruopeitem: true,
        },
        orderBy: { id: 'asc' },
      });
      for (const d of dets) {
        const key = d.factura_cab_id;
        if (!itemsPorFactura.has(key)) itemsPorFactura.set(key, []);
        itemsPorFactura.get(key)!.push({
          descripcion: d.ddesproser || '-',
          cantidad: Number(d.dcantproser || 0),
          subtotal: Number(d.dtotbruopeitem || 0),
        });
      }
    }

    const detalles = recibo.recibo_cobro_detalle.map((det) => {
      const cuota = det.factura_cuotas;
      const facturaCab = cuota?.factura_cab;
      const facturaNumero = facturaCab ? `${facturaCab.dest}-${facturaCab.dpunexp}-${facturaCab.dnumdoc}` : '-';

      return {
        factura_numero: facturaNumero,
        nro_cuota: cuota?.nro_cuota || null,
        total_cuotas: facturaCab?.dcuotas ?? null,
        fecha_vencimiento: cuota?.dvenccuo ? new Date(cuota.dvenccuo).toISOString() : null,
        medio_pago: det.medio_pago?.descripcion || '-',
        monto_original: Number(det.monto_original || 0),
        monto_pagado: Number(det.monto_pagado || 0),
        descuento_aplicado: Number(det.descuento_aplicado || 0),
        // Interés cobrado con esta cuota (mora + interés manual). El ticket
        // ESC/POS lo imprime bajo el monto, igual que el PDF.
        mora_monto: Number(det.mora_monto || 0),
        es_pago_parcial: det.es_pago_parcial || false,
        saldo_factura: facturaCab?.id ? (saldosPorFactura.get(facturaCab.id) ?? null) : null,
        items_factura: facturaCab?.id ? (itemsPorFactura.get(facturaCab.id) ?? null) : null,
        banco: det.bancos?.descripcion || null,
        numero_cheque: det.numero_cheque || null,
        numero_tarjeta: det.numero_tarjeta || null,
        codigo_autorizacion: det.codigo_autorizacion || null,
        titular_tarjeta: det.titular_tarjeta || null,
        razon_social_procesadora: det.razon_social_procesadora || null,
      };
    });

    const formasPagoMap = new Map<
      string,
      {
        descripcion: string;
        monto: number;
        banco: string | null;
        numero_cheque: string | null;
        numero_tarjeta: string | null;
        codigo_autorizacion: string | null;
      }
    >();

    for (const det of recibo.recibo_cobro_detalle) {
      const medioPagoId = det.medio_pago_id;
      const medioPagoDesc = det.medio_pago?.descripcion || 'Otro';
      if (!formasPagoMap.has(medioPagoId)) {
        formasPagoMap.set(medioPagoId, {
          descripcion: medioPagoDesc,
          monto: 0,
          banco: det.bancos?.descripcion || null,
          numero_cheque: det.numero_cheque || null,
          numero_tarjeta: det.numero_tarjeta || null,
          codigo_autorizacion: det.codigo_autorizacion || null,
        });
      }
      const fp = formasPagoMap.get(medioPagoId)!;
      // Con el interés incluido: el cliente entregó capital + interés por ese
      // medio, y así el bloque de formas de pago cierra con el TOTAL del ticket.
      fp.monto += Number(det.monto_pagado || 0) + Number(det.mora_monto || 0);
    }

    // Interés que no cuelga de ninguna cuota (el cargado a la cuenta): se suma
    // al primer medio de pago para que las formas de pago cierren con el total.
    const interesEnDetalles = recibo.recibo_cobro_detalle.reduce(
      (acc, det) => acc + Number(det.mora_monto || 0),
      0,
    );
    const interesSinCuota = Number(recibo.mora_total ?? 0) - interesEnDetalles;
    const primeraFormaPago = formasPagoMap.values().next().value;
    if (interesSinCuota > 0.009 && primeraFormaPago) {
      primeraFormaPago.monto += interesSinCuota;
    }

    let saldoCuentaCliente = 0;
    if (recibo.cliente_id) {
      const saldoRow = await this.prisma.$queryRaw<{ saldo: number | null }[]>`
        SELECT COALESCE(SUM(
          CASE
            WHEN LOWER(fcu.estado) IN ('pagado','anulado','anulada') THEN 0
            ELSE COALESCE(NULLIF(fcu.saldo_pendiente, 0), fcu.dmoncuota, 0)
          END
        ), 0)::float AS saldo
        FROM factura_cuotas fcu
        JOIN factura_cab fc ON fc.id = fcu.factura_cab_id
        WHERE fc.cliente_id = ${recibo.cliente_id}::uuid
          AND fc.empresa_id = ${recibo.empresa_id}::uuid
      `;
      saldoCuentaCliente = Number(saldoRow?.[0]?.saldo || 0);
    }

    return {
      formato,
      tipo: tipo_retorno,
      ancho_papel,
      empresa: {
        razon_social: empresa.razon_social || '',
        ruc: empresa.ruc,
        dv: empresa.dv,
        direccion: '',
        telefono: empresa.celular || '',
        email: empresa.email || '',
        logo_url: logoEmisor(recibo.empresas_sucursales, empresa),
        nombre_fantasia: nombreFantasiaEmisor(recibo.empresas_sucursales, empresa),
      },
      sucursal: recibo.empresas_sucursales
        ? {
            nombre: recibo.empresas_sucursales.descripcion || '',
            direccion: recibo.empresas_sucursales.direccion || '',
            telefono: recibo.empresas_sucursales.telefono || '',
          }
        : null,
      cobrador: recibo.cobrador
        ? {
            nombre: [recibo.cobrador.nombre, recibo.cobrador.apellido].filter(Boolean).join(' '),
          }
        : null,
      numero_recibo: recibo.numero_recibo,
      fecha_emision: recibo.fecha_emision?.toISOString(),
      estado: recibo.estado,
      moneda: moneda
        ? { codigo: moneda.codigo, simbolo: moneda.simbolo, decimales: moneda.decimales }
        : { codigo: 'PYG', simbolo: '₲', decimales: 0 },
      monto_total: Number(recibo.monto_total),
      monto_recibido: Number(recibo.monto_recibido || recibo.monto_total),
      descuento_monto: Number(recibo.descuento_monto || 0),
      descuento_porcentaje: recibo.descuento_porcentaje ? Number(recibo.descuento_porcentaje) : null,
      motivo_descuento: recibo.motivo_descuento || null,
      cotizacion: recibo.cotizacion ? Number(recibo.cotizacion) : null,
      mora_total: Number(recibo.mora_total ?? 0),
      concepto: 'Pago de facturas crédito',
      observacion: recibo.observacion || null,
      cliente: {
        razon_social: persona?.razon_social || '',
        documento: documentoCliente,
        tipo_documento: tipoDocumento,
        direccion: persona?.direccion || '',
        telefono: persona?.telefono || persona?.celular || '',
        email: persona?.email || '',
      },
      detalles,
      formas_pago: Array.from(formasPagoMap.values()),
      saldo_cuenta: saldoCuentaCliente,
    };
  }

  // ==================== MODIFICAR FECHA ====================

  async actualizarFecha(id: string, fecha_registro: string, motivo: string, empresaId: string, userId: string) {
    const recibo = await this.prisma.recibos_cobro.findUnique({
      where: { id },
      select: { id: true, empresa_id: true, estado: true, fecha_registro: true },
    });

    if (!recibo) throw new NotFoundException('Recibo no encontrado');
    if (recibo.empresa_id !== empresaId) throw new BadRequestException('El recibo no pertenece a esta empresa');
    if (recibo.estado === 'anulado') throw new BadRequestException('No se puede modificar un recibo anulado');
    if (!motivo?.trim()) throw new BadRequestException('El motivo es obligatorio para modificar la fecha');

    const fechaAnterior = recibo.fecha_registro;
    const fechaNueva = new Date(fecha_registro);

    const updated = await this.prisma.recibos_cobro.update({
      where: { id },
      data: { fecha_registro: fechaNueva },
      select: { id: true, numero_recibo: true, fecha_registro: true, fecha_emision: true },
    });

    await this.auditService.log({
      entity_type: 'recibos_cobro',
      entity_id: id,
      action: 'UPDATE',
      old_value: { fecha_registro: fechaAnterior },
      new_value: { fecha_registro: fechaNueva },
      descripcion: `Modificación de fecha: ${motivo}`,
      user_id: userId,
      empresa_id: empresaId,
    });

    return { success: true, data: updated };
  }

  // ==================== REINTENTO MANUAL DE EMISIÓN DE INTERESES ====================

  /**
   * Reintenta la emisión SIFEN de la factura por intereses moratorios.
   * Resetea `intento_emision` y `error_emision` (para que el cron también pueda
   * volver a intentarlo si este intento manual también falla) y dispara
   * `emitirFacturaParaRecibo` en el momento.
   */
  async reintentarEmisionIntereses(reciboId: string, empresaId: string, userId: string) {
    const recibo = await this.prisma.recibos_cobro.findFirst({
      where: { id: reciboId, empresa_id: empresaId },
      select: { id: true, numero_recibo: true, estado: true },
    });
    if (!recibo) throw new NotFoundException(`Recibo ${reciboId} no encontrado`);
    if (recibo.estado === 'anulado') {
      throw new BadRequestException('El recibo está anulado — no se puede reintentar la emisión');
    }

    const pendientes = await this.prisma.cob_interes_cobrado.findMany({
      where: {
        empresa_id: empresaId,
        recibo_cobro_id: reciboId,
        factura_cab_id: null,
        tipo_comprobante: 'FACTURA',
        monto_interes_cobrado: { gt: 0 },
      },
      select: { id: true, intento_emision: true, error_emision: true },
    });
    if (pendientes.length === 0) {
      throw new BadRequestException('No hay facturas de intereses pendientes de emisión para este recibo');
    }

    await this.prisma.cob_interes_cobrado.updateMany({
      where: { id: { in: pendientes.map((p) => p.id) } },
      data: { intento_emision: 0, error_emision: null },
    });

    const resultado = await this.interesesMoratorios.emitirFacturaParaRecibo(empresaId, reciboId, userId);

    await this.auditService.log({
      empresa_id: empresaId,
      user_id: userId,
      action: resultado.factura_cab_id ? 'UPDATE' : 'ERROR',
      entity_type: 'cob_interes_cobrado',
      entity_id: reciboId,
      descripcion: resultado.factura_cab_id
        ? `Reintento manual de emisión SIFEN exitoso | Recibo ${recibo.numero_recibo} | Factura ${resultado.factura_cab_id}`
        : `Reintento manual de emisión SIFEN falló | Recibo ${recibo.numero_recibo} | ${resultado.error ?? 'sin detalle'}`,
      new_value: {
        recibo_id: reciboId,
        recibo_numero: recibo.numero_recibo,
        cuotas_reintentadas: pendientes.length,
        emitidos: resultado.emitidos,
        saltados: resultado.saltados,
        error: resultado.error ?? null,
      },
    });

    if (!resultado.factura_cab_id && resultado.error) {
      throw new BadRequestException(resultado.error);
    }
    return {
      factura_cab_id: resultado.factura_cab_id,
      emitidos: resultado.emitidos,
      saltados: resultado.saltados,
    };
  }

  // ==================== DASHBOARD MOROSIDAD ====================

  async getDashboardMorosidad(empresaId: string) {
    const cuotasVencidas = await this.prisma.$queryRaw<
      Array<{
        cliente_id: string;
        razon_social: string;
        total_vencido: number;
        dias_promedio_vencimiento: number;
        cuotas_vencidas: number;
      }>
    >`
      SELECT
        c.id AS cliente_id,
        p.razon_social,
        COALESCE(SUM(fq.saldo_pendiente), 0)::float AS total_vencido,
        COALESCE(AVG(EXTRACT(DAY FROM NOW() - fq.dvenccuo)), 0)::float AS dias_promedio_vencimiento,
        COUNT(fq.id)::int AS cuotas_vencidas
      FROM factura_cuotas fq
      INNER JOIN factura_cab fc ON fc.id = fq.factura_cab_id
      INNER JOIN clientes c ON c.id = fc.cliente_id
      INNER JOIN personas p ON p.id = c.persona_id
      WHERE fc.empresa_id = ${empresaId}::uuid
        AND fq.estado NOT IN ('pagado', 'Pagado')
        AND fq.dvenccuo < NOW()
        AND (fq.saldo_pendiente IS NULL OR fq.saldo_pendiente > 0)
      GROUP BY c.id, p.razon_social
      ORDER BY total_vencido DESC
    `;

    const moraCobrada = await this.prisma.$queryRaw<Array<{ mora_mes: number; mora_anio: number }>>`
      SELECT
        COALESCE(SUM(CASE WHEN DATE_TRUNC('month', r.fecha_emision) = DATE_TRUNC('month', NOW()) THEN r.mora_total ELSE 0 END), 0)::float AS mora_mes,
        COALESCE(SUM(CASE WHEN EXTRACT(year FROM r.fecha_emision) = EXTRACT(year FROM NOW()) THEN r.mora_total ELSE 0 END), 0)::float AS mora_anio
      FROM recibos_cobro r
      WHERE r.empresa_id = ${empresaId}::uuid
        AND r.estado != 'anulado'
        AND r.mora_total > 0
    `;

    const moraPorMes = await this.prisma.$queryRaw<Array<{ mes: string; mora_cobrada: number }>>`
      SELECT
        TO_CHAR(DATE_TRUNC('month', r.fecha_emision), 'YYYY-MM') AS mes,
        COALESCE(SUM(r.mora_total), 0)::float AS mora_cobrada
      FROM recibos_cobro r
      WHERE r.empresa_id = ${empresaId}::uuid
        AND r.estado != 'anulado'
        AND r.fecha_emision >= NOW() - INTERVAL '6 months'
      GROUP BY DATE_TRUNC('month', r.fecha_emision)
      ORDER BY DATE_TRUNC('month', r.fecha_emision) ASC
    `;

    const aging = await this.prisma.$queryRaw<Array<{ bucket: string; total_vencido: number; cantidad: number }>>`
      SELECT
        CASE
          WHEN EXTRACT(DAY FROM NOW() - fq.dvenccuo) <= 30 THEN '0-30'
          WHEN EXTRACT(DAY FROM NOW() - fq.dvenccuo) <= 60 THEN '31-60'
          WHEN EXTRACT(DAY FROM NOW() - fq.dvenccuo) <= 90 THEN '61-90'
          ELSE '90+'
        END AS bucket,
        COALESCE(SUM(fq.saldo_pendiente), 0)::float AS total_vencido,
        COUNT(fq.id)::int AS cantidad
      FROM factura_cuotas fq
      INNER JOIN factura_cab fc ON fc.id = fq.factura_cab_id
      WHERE fc.empresa_id = ${empresaId}::uuid
        AND fq.estado NOT IN ('pagado', 'Pagado')
        AND fq.dvenccuo < NOW()
        AND (fq.saldo_pendiente IS NULL OR fq.saldo_pendiente > 0)
      GROUP BY 1
      ORDER BY 1
    `;

    const totalVencido = cuotasVencidas.reduce((s, r) => s + Number(r.total_vencido), 0);
    const moraData = moraCobrada[0] ?? { mora_mes: 0, mora_anio: 0 };

    return {
      resumen: {
        total_vencido: totalVencido,
        clientes_morosos: cuotasVencidas.length,
        mora_cobrada_mes: Number(moraData.mora_mes),
        mora_cobrada_anio: Number(moraData.mora_anio),
      },
      aging_buckets: aging,
      mora_por_mes: moraPorMes,
      top_morosos: cuotasVencidas.slice(0, 10),
    };
  }

  // ==================== HOJA DE RUTA ====================

  async getHojaDeRuta(empresaId: string, cobradorId: string, estado?: string, diasProximos?: number) {
    const rows = await this.prisma.$queryRaw<
      Array<{
        cliente_id: string;
        razon_social: string;
        nro_documento: string;
        direccion: string;
        nro_casa: string;
        referencia_domicilio: string;
        celular: string;
        telefono: string;
        zona: string;
        saldo_pendiente: number;
        dia_fijo_pago_1: number | null;
        dia_fijo_pago_2: number | null;
        dia_cobro_semana: number | null;
        promesa_fecha: Date | null;
        promesa_monto: number | null;
        promesa_notas: string | null;
        promesa_estado: string | null;
        observacion_solicitud: string | null;
        proxima_cuota_fecha: Date | null;
        proxima_cuota_monto: number | null;
        total_cuotas_cobrador: number | null;
        dias_vencido_max: number;
        cuotas_vencidas: number;
      }>
    >`
      SELECT DISTINCT ON (c.id)
        c.id AS cliente_id,
        p.razon_social,
        p.nro_documento,
        p.direccion,
        p.nro_casa,
        c.referencia_domicilio,
        p.celular,
        p.telefono,
        c.zona,
        COALESCE(SUM(cc.saldo_pendiente) OVER (PARTITION BY c.id), 0)::float AS saldo_pendiente,
        sc.dia_fijo_pago_1,
        sc.dia_fijo_pago_2,
        sc.dia_cobro_semana,
        pp.fecha_prometida AS promesa_fecha,
        pp.monto_prometido::float AS promesa_monto,
        pp.notas AS promesa_notas,
        pp.estado AS promesa_estado,
        sc.observaciones AS observacion_solicitud,
        cuota_info.proxima_cuota_fecha,
        cuota_monto.proxima_cuota_monto,
        cuotas_total.total_cuotas_cobrador,
        COALESCE(cuota_info.dias_vencido_max, 0)::int AS dias_vencido_max,
        COALESCE(cuota_info.cuotas_vencidas, 0)::int AS cuotas_vencidas
      FROM clientes c
      INNER JOIN personas p ON p.id = c.persona_id
      INNER JOIN factura_cab fc ON fc.cliente_id = c.id AND fc.empresa_id = ${empresaId}::uuid AND fc.cobrador_id = ${cobradorId}::uuid AND ${sqlFacturaCobrable('fc')}
      LEFT JOIN cuentas_cobrar cc ON cc.cliente_id = c.id AND cc.empresa_id = ${empresaId}::uuid AND cc.estado != 'pagada'
      LEFT JOIN solicitudes_credito sc ON sc.cliente_id = c.id AND sc.empresa_id = ${empresaId}::uuid AND sc.estado = 'aprobado'
      LEFT JOIN LATERAL (
        SELECT fecha_prometida, monto_prometido, notas, estado
        FROM promesas_pago
        WHERE cliente_id = c.id AND empresa_id = ${empresaId}::uuid AND estado IN ('pendiente', 'parcial')
        ORDER BY fecha_prometida DESC
        LIMIT 1
      ) pp ON true
      LEFT JOIN LATERAL (
        SELECT
          MIN(fq.dvenccuo) AS proxima_cuota_fecha,
          MAX(GREATEST(0, EXTRACT(DAY FROM NOW() - fq.dvenccuo)::int)) FILTER (WHERE fq.dvenccuo < CURRENT_DATE) AS dias_vencido_max,
          COUNT(*) FILTER (WHERE fq.dvenccuo < CURRENT_DATE AND fq.estado NOT IN ('pagado', 'Pagado')) AS cuotas_vencidas
        FROM factura_cuotas fq
        INNER JOIN factura_cab fc2 ON fc2.id = fq.factura_cab_id
        WHERE fc2.cliente_id = c.id
          AND fc2.empresa_id = ${empresaId}::uuid
          AND fc2.cobrador_id = ${cobradorId}::uuid
          AND ${sqlFacturaCobrable('fc2')}
          AND fq.estado NOT IN ('pagado', 'Pagado')
      ) cuota_info ON true
      LEFT JOIN LATERAL (
        SELECT fq.dmoncuota::float AS proxima_cuota_monto
        FROM factura_cuotas fq
        INNER JOIN factura_cab fc3 ON fc3.id = fq.factura_cab_id
        WHERE fc3.cliente_id = c.id
          AND fc3.empresa_id = ${empresaId}::uuid
          AND fc3.cobrador_id = ${cobradorId}::uuid
          AND ${sqlFacturaCobrable('fc3')}
          AND fq.estado NOT IN ('pagado', 'Pagado')
        ORDER BY fq.dvenccuo ASC
        LIMIT 1
      ) cuota_monto ON true
      LEFT JOIN LATERAL (
        SELECT SUM(fq.dmoncuota)::float AS total_cuotas_cobrador
        FROM factura_cuotas fq
        INNER JOIN factura_cab fc3 ON fc3.id = fq.factura_cab_id
        WHERE fc3.cliente_id = c.id
          AND fc3.empresa_id = ${empresaId}::uuid
          AND fc3.cobrador_id = ${cobradorId}::uuid
          AND ${sqlFacturaCobrable('fc3')}
          AND fq.estado NOT IN ('pagado', 'Pagado')
      ) cuotas_total ON true
      ORDER BY c.id, sc.created_at DESC
    `;

    // Apply estado filter
    const hoy = new Date();
    hoy.setHours(0, 0, 0, 0);
    let filtered = rows;

    if (estado === 'vencido') {
      filtered = rows.filter((r) => Number(r.cuotas_vencidas) > 0);
    } else if (estado === 'al_dia') {
      filtered = rows.filter((r) => Number(r.cuotas_vencidas) === 0 && Number(r.saldo_pendiente) > 0);
    } else if (estado === 'sin_deuda') {
      filtered = rows.filter((r) => Number(r.saldo_pendiente) === 0);
    } else if (estado === 'proximos' && diasProximos) {
      const limite = new Date(hoy);
      limite.setDate(limite.getDate() + diasProximos);
      filtered = rows.filter((r) => {
        if (!r.proxima_cuota_fecha) return false;
        const f = new Date(r.proxima_cuota_fecha);
        return f >= hoy && f <= limite;
      });
    } else if (estado === 'con_promesa') {
      filtered = rows.filter((r) => r.promesa_fecha != null);
    }

    // Sort: vencidos primero (mayor dias_vencido), luego por saldo desc
    filtered.sort((a, b) => {
      if (Number(b.dias_vencido_max) !== Number(a.dias_vencido_max))
        return Number(b.dias_vencido_max) - Number(a.dias_vencido_max);
      return Number(b.saldo_pendiente) - Number(a.saldo_pendiente);
    });

    // Fetch products for each client (up to 3 items per client)
    const clienteIds = [...new Set(filtered.map((r) => r.cliente_id))];
    type ProductoRow = { cliente_id: string; descripcion: string; cantidad: number; monto_cuota: number };
    const productos =
      clienteIds.length > 0
        ? await this.prisma.$queryRaw<ProductoRow[]>`
          SELECT fc.cliente_id, fd.ddesproser AS descripcion, fd.dcantproser::float AS cantidad, fd.dtotopeitem::float AS monto_cuota
          FROM factura_det fd
          INNER JOIN factura_cab fc ON fc.id = fd.factura_cab_id
          WHERE fc.cliente_id = ANY(${clienteIds}::uuid[])
            AND fc.empresa_id = ${empresaId}::uuid
            AND fc.cobrador_id = ${cobradorId}::uuid
            AND ${sqlFacturaCobrable('fc')}
          ORDER BY fc.cliente_id, fd.created_at
        `
        : [];

    const productosByCliente: Record<string, ProductoRow[]> = {};
    for (const prod of productos) {
      if (!productosByCliente[prod.cliente_id]) productosByCliente[prod.cliente_id] = [];
      if (productosByCliente[prod.cliente_id].length < 3) {
        productosByCliente[prod.cliente_id].push(prod);
      }
    }

    const diasSemana = ['', 'Lun', 'Mar', 'Mié', 'Jue', 'Vie', 'Sáb', 'Dom'];

    return filtered.map((r) => ({
      cliente_id: r.cliente_id,
      razon_social: r.razon_social,
      nro_documento: r.nro_documento,
      direccion: [r.direccion, r.nro_casa].filter(Boolean).join(' '),
      referencia_domicilio: r.referencia_domicilio,
      celular: r.celular,
      telefono: r.telefono,
      zona: r.zona,
      saldo_pendiente: Number(r.saldo_pendiente),
      cuotas_vencidas: Number(r.cuotas_vencidas),
      dias_vencido_max: Number(r.dias_vencido_max),
      proxima_cuota_fecha: r.proxima_cuota_fecha ? formatDateResponse(r.proxima_cuota_fecha, true) : null,
      proxima_cuota_monto: r.proxima_cuota_monto ? Number(r.proxima_cuota_monto) : null,
      total_cuotas_cobrador: r.total_cuotas_cobrador ? Number(r.total_cuotas_cobrador) : null,
      dia_cobro: r.dia_fijo_pago_1
        ? r.dia_fijo_pago_2
          ? `Días ${r.dia_fijo_pago_1} y ${r.dia_fijo_pago_2}`
          : `Día ${r.dia_fijo_pago_1}`
        : r.dia_cobro_semana
          ? diasSemana[r.dia_cobro_semana] || null
          : null,
      promesa: r.promesa_fecha
        ? {
            fecha: formatDateResponse(r.promesa_fecha),
            monto: Number(r.promesa_monto),
            notas: r.promesa_notas,
            estado: r.promesa_estado,
          }
        : null,
      observacion: r.observacion_solicitud,
      productos: (productosByCliente[r.cliente_id] || []).map((p) => ({
        descripcion: p.descripcion,
        cantidad: Number(p.cantidad),
        monto_cuota: Number(p.monto_cuota),
      })),
    }));
  }

  async getClientesSinCobrador(empresaId: string) {
    const rows = await this.prisma.$queryRaw<
      Array<{
        cliente_id: string;
        razon_social: string;
        nro_documento: string;
        direccion: string;
        celular: string;
        saldo_pendiente: number;
      }>
    >`
      SELECT DISTINCT
        c.id AS cliente_id,
        p.razon_social,
        p.nro_documento,
        CONCAT_WS(' ', p.direccion, p.nro_casa) AS direccion,
        p.celular,
        COALESCE(SUM(cc.saldo_pendiente), 0)::float AS saldo_pendiente
      FROM clientes c
      INNER JOIN personas p ON p.id = c.persona_id
      INNER JOIN cuentas_cobrar cc ON cc.cliente_id = c.id AND cc.empresa_id = ${empresaId}::uuid AND cc.estado != 'pagada'
      WHERE NOT EXISTS (
          SELECT 1 FROM factura_cab fc2
          WHERE fc2.cliente_id = c.id
            AND fc2.empresa_id = ${empresaId}::uuid
            AND fc2.cobrador_id IS NOT NULL
            AND ${sqlFacturaCobrable('fc2')}
        )
      GROUP BY c.id, p.razon_social, p.nro_documento, p.direccion, p.nro_casa, p.celular
      ORDER BY p.razon_social
    `;
    return rows;
  }

  async asignarCobradorACliente(clienteId: string, cobradorId: string, empresaId: string) {
    const cobrador = await this.prisma.vendedores_cobradores.findFirst({
      where: { id: cobradorId, empresa_id: empresaId, active: true },
    });
    if (!cobrador) throw new NotFoundException('Cobrador no encontrado');

    const updated = await this.prisma.factura_cab.updateMany({
      where: {
        cliente_id: clienteId,
        empresa_id: empresaId,
        estado: { notIn: ESTADOS_FACTURA_NO_COBRABLE_PRISMA },
        cobrador_id: null,
      },
      data: { cobrador_id: cobradorId },
    });

    return { updated: updated.count };
  }

  async getDetalleClientePanelCobrador(clienteId: string, empresaId: string) {
    const cliente = await this.prisma.clientes.findUnique({
      where: { id: clienteId },
      include: {
        personas: {
          select: { razon_social: true, celular: true, telefono: true, direccion: true, nro_casa: true },
        },
      },
    });
    if (!cliente) throw new NotFoundException('Cliente no encontrado');

    const solicitud = await this.prisma.solicitud_credito.findFirst({
      where: { cliente_id: clienteId, empresa_id: empresaId, estado: 'aprobado' },
      orderBy: { created_at: 'desc' },
      select: { dia_fijo_pago_1: true, dia_fijo_pago_2: true, dia_cobro_semana: true },
    });

    const promesa = await this.prisma.promesas_pago.findFirst({
      where: { cliente_id: clienteId, empresa_id: empresaId, estado: { in: ['pendiente', 'parcial'] } },
      orderBy: { fecha_prometida: 'desc' },
      select: { fecha_prometida: true, monto_prometido: true, notas: true, estado: true },
    });

    type ProdRow = { descripcion: string; cantidad: number; monto_cuota: number };
    const productos = await this.prisma.$queryRaw<ProdRow[]>`
      SELECT fd.ddesproser AS descripcion, fd.dcantproser::float AS cantidad, fd.dtotopeitem::float AS monto_cuota
      FROM factura_det fd
      INNER JOIN factura_cab fc ON fc.id = fd.factura_cab_id
      WHERE fc.cliente_id = ${clienteId}::uuid
        AND fc.empresa_id = ${empresaId}::uuid
        AND ${sqlFacturaCobrable('fc')}
       
      ORDER BY fc.created_at DESC
      LIMIT 10
    `;

    const diasSemana = ['', 'Lun', 'Mar', 'Mié', 'Jue', 'Vie', 'Sáb', 'Dom'];
    const p = cliente.personas;

    return {
      cliente_id: clienteId,
      razon_social: p?.razon_social,
      direccion: [p?.direccion, p?.nro_casa].filter(Boolean).join(' '),
      referencia_domicilio: cliente.referencia_domicilio,
      celular: p?.celular,
      telefono: p?.telefono,
      dia_cobro: solicitud?.dia_fijo_pago_1
        ? solicitud.dia_fijo_pago_2
          ? `Días ${solicitud.dia_fijo_pago_1} y ${solicitud.dia_fijo_pago_2}`
          : `Día ${solicitud.dia_fijo_pago_1}`
        : solicitud?.dia_cobro_semana
          ? diasSemana[solicitud.dia_cobro_semana] || null
          : null,
      ultima_promesa: promesa
        ? {
            fecha: formatDateResponse(promesa.fecha_prometida),
            monto: Number(promesa.monto_prometido),
            notas: promesa.notas,
            estado: promesa.estado,
          }
        : null,
      productos: productos.map((prod) => ({
        descripcion: prod.descripcion,
        cantidad: Number(prod.cantidad),
        monto_cuota: Number(prod.monto_cuota),
      })),
    };
  }

  async generateHojaDeRutaPdf(empresaId: string, cobradorId: string, options: { tipo_retorno?: string } = {}) {
    const { tipo_retorno = 'view' } = options;

    const [clientes, cobrador, empresa] = await Promise.all([
      this.getHojaDeRuta(empresaId, cobradorId),
      this.prisma.vendedores_cobradores.findFirst({
        where: { id: cobradorId, empresa_id: empresaId },
        select: { nombre: true },
      }),
      this.prisma.empresas.findUnique({
        where: { id: empresaId },
        select: { razon_social: true, nombre_fantasia: true, ruc: true, dv: true, logo: true },
      }),
    ]);

    if (!cobrador) throw new NotFoundException('Cobrador no encontrado');

    const payload = {
      tipo: tipo_retorno,
      empresa: {
        razon_social: empresa?.razon_social,
        // Nombre comercial y logo de la empresa, como el resto de los PDFs.
        nombre_fantasia: empresa?.nombre_fantasia ?? null,
        logo_url: empresa?.logo ?? null,
        ruc: empresa ? `${empresa.ruc}-${empresa.dv}` : '',
      },
      cobrador: { nombre: cobrador.nombre },
      fecha: new Date().toLocaleDateString('es-PY', { day: '2-digit', month: '2-digit', year: 'numeric' }),
      clientes,
    };

    const url = `${envs.apiGeneradorPDF}/api/hoja-de-ruta/generate-pdf`;

    try {
      const response = await fetch(url, {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify(payload),
      });

      if (!response.ok) {
        const errorBody = await response.text();
        throw new BadRequestException(`Error al generar PDF: ${errorBody}`);
      }

      if (tipo_retorno === 'base64') {
        const json = (await response.json()) as { status: string; pdf_base64?: string; message?: string };
        if (json.status !== 'success') throw new BadRequestException(json.message || 'Error al generar PDF');
        return { base64: json.pdf_base64, contentType: 'application/json' };
      }

      const arrayBuffer = await response.arrayBuffer();
      const pdfBuffer = Buffer.from(arrayBuffer);
      return { pdfBuffer, contentType: 'application/pdf' };
    } catch (error) {
      if (error instanceof BadRequestException) throw error;
      const message = error instanceof Error ? error.message : 'Error desconocido';
      throw new BadRequestException(`No se pudo generar el PDF de hoja de ruta: ${message}`);
    }
  }

  /**
   * Decide a qué sesión de caja se atribuye un cobro:
   *   1. Si el cliente envió sesion_caja_id, validamos que esté ABIERTA y que la caja
   *      esté entre las asignadas al usuario.
   *   2. Si no envió, buscamos una sesión ABIERTA del propio usuario.
   *   3. Si tampoco, tomamos una sesión ABIERTA en alguna caja asignada al usuario.
   *   4. Si el usuario no tiene asignaciones registradas, dejamos pasar null
   *      (compatibilidad con cobros externos / usuarios sin caja).
   *
   * `soloSesionPropia` (cobros desde la app del cobrador) recorta todo eso a la
   * sesión que abrió el propio usuario. El cobrador externo no tiene caja: su
   * dinero entra por el módulo de rendiciones, así que el recibo tiene que
   * quedar SIN sesión de caja. Los pasos 1, 3 y 4 le imputaban la caja de otro:
   * el 1 porque solo comprueba que la caja esté entre las asignadas — y estar
   * asignado a la sucursal entera alcanza todas sus cajas, incluidas las
   * personales de otros cajeros —, y el 4 porque un usuario sin asignaciones se
   * trata como "sin restricción".
   */
  private async resolverSesionCajaParaCobro(
    sesionCajaId: string | undefined | null,
    userId: string,
    empresaId: string,
    soloSesionPropia = false,
  ): Promise<string | undefined> {
    if (soloSesionPropia) {
      const propiaAbierta = await this.prisma.sesiones_caja.findFirst({
        where: { empresa_id: empresaId, estado: 'ABIERTA', user_id: userId },
        orderBy: { fecha_apertura: 'desc' },
        select: { id: true },
      });
      if (!propiaAbierta) return undefined;
      // Si mandó una sesión y no es la suya, se ignora en vez de rechazar el
      // cobro: el cobro es válido, lo que no corresponde es la caja.
      if (sesionCajaId && sesionCajaId !== propiaAbierta.id) {
        this.logger.warn(
          `Cobro de ${userId} pidió la sesión de caja ${sesionCajaId}, que no es suya. ` +
            `Se imputa sin caja (se rinde por rendiciones).`,
          'CobrosService',
        );
        return undefined;
      }
      return propiaAbierta.id;
    }

    const asignaciones = await this.prisma.asignacion_sucursal_caja_usuario.findMany({
      where: { usuario_id: userId, sucursal: { empresa_id: empresaId } },
      include: { sucursal: { include: { cajas: { select: { id: true } } } } },
    });
    const cajasAsignadas: Set<string> | null = asignaciones.length === 0 ? null : new Set();
    if (cajasAsignadas) {
      for (const a of asignaciones) {
        if (a.caja_id) cajasAsignadas.add(a.caja_id);
        else if (a.sucursal?.cajas) for (const c of a.sucursal.cajas) cajasAsignadas.add(c.id);
      }
    }

    if (sesionCajaId) {
      const sesion = await this.prisma.sesiones_caja.findFirst({
        where: { id: sesionCajaId, empresa_id: empresaId },
        select: { id: true, caja_id: true, estado: true },
      });
      if (!sesion) throw new BadRequestException('La sesión de caja indicada no existe');
      if (sesion.estado !== 'ABIERTA') {
        throw new BadRequestException('La sesión de caja indicada no está abierta');
      }
      if (cajasAsignadas && !cajasAsignadas.has(sesion.caja_id)) {
        throw new BadRequestException(
          'No podés cobrar en una caja que no tenés asignada. Abrí tu caja antes de cobrar.',
        );
      }
      return sesionCajaId;
    }

    // Sin sesion explícita: primero buscamos la sesión abierta del propio usuario.
    const propia = await this.prisma.sesiones_caja.findFirst({
      where: { empresa_id: empresaId, estado: 'ABIERTA', user_id: userId },
      orderBy: { fecha_apertura: 'desc' },
      select: { id: true },
    });
    if (propia) return propia.id;

    if (cajasAsignadas && cajasAsignadas.size > 0) {
      const enAsignada = await this.prisma.sesiones_caja.findFirst({
        where: {
          empresa_id: empresaId,
          estado: 'ABIERTA',
          caja_id: { in: [...cajasAsignadas] },
        },
        orderBy: { fecha_apertura: 'desc' },
        select: { id: true },
      });
      if (enAsignada) return enAsignada.id;
    }

    // Usuario sin asignaciones y sin sesión: lo dejamos pasar sin sesion_caja_id
    // (cobros externos / migrados). Si tiene asignaciones pero ninguna abierta,
    // tampoco bloqueamos: queda como cobro sin movimiento de caja.
    return undefined;
  }
}
