// ============================================================
// PAYMENTS MODULE
// Baixa manual de pagamentos + automações de cobrança
// ============================================================
import {
  Entity, PrimaryGeneratedColumn, Column,
  CreateDateColumn, UpdateDateColumn,
} from 'typeorm';

@Entity('charges')
export class Charge {
  @PrimaryGeneratedColumn('uuid')
  id: string;

  @Column({ name: 'tenant_id' })
  tenantId: string;

  @Column({ name: 'contract_id', nullable: true })
  contractId: string;

  @Column({ name: 'client_id' })
  clientId: string;

  @Column({ name: 'created_by', nullable: true })
  createdBy: string;

  @Column({ length: 20 })
  number: string;

  @Column({ type: 'text', nullable: true })
  description: string;

  @Column({ type: 'numeric', precision: 15, scale: 2 })
  amount: number;

  @Column({ type: 'numeric', precision: 15, scale: 2, default: 0 })
  discount: number;

  @Column({ type: 'numeric', precision: 15, scale: 2, default: 0 })
  fine: number;

  @Column({ type: 'numeric', precision: 15, scale: 2, default: 0 })
  interest: number;

  @Column({ name: 'amount_paid', type: 'numeric', precision: 15, scale: 2, nullable: true })
  amountPaid: number;

  @Column({ name: 'due_date', type: 'date' })
  dueDate: string;

  @Column({ name: 'paid_at', type: 'timestamptz', nullable: true })
  paidAt: Date;

  @Column({ name: 'payment_method', nullable: true })
  paymentMethod: string;  // pix | boleto | credit_card | manual | transfer

  @Column({ name: 'payment_reference', nullable: true })
  paymentReference: string;

  @Column({ name: 'payment_notes', type: 'text', nullable: true })
  paymentNotes: string;

  @Column({ name: 'notified_7d', default: false })
  notified7d: boolean;

  @Column({ name: 'notified_1d', default: false })
  notified1d: boolean;

  @Column({ name: 'notified_overdue', default: false })
  notifiedOverdue: boolean;

  // pending | paid | overdue | cancelled | refunded
  @Column({ default: 'pending' })
  status: string;

  @Column({ name: 'cancelled_at', type: 'timestamptz', nullable: true })
  cancelledAt: Date;

  // ── Campos de gateway (v.api) ──────────────────────────────────────────
  @Column({ nullable: true })
  gateway: string;  // 'vapi' | null

  @Column({ name: 'gateway_charge_id', nullable: true })
  gatewayChargeId: string;

  @Column({ name: 'pix_copy_paste', type: 'text', nullable: true })
  pixCopyPaste: string;

  @Column({ name: 'pix_txid', nullable: true })
  pixTxid: string;

  @Column({ name: 'boleto_url', type: 'text', nullable: true })
  boletoUrl: string;

  @Column({ name: 'boleto_barcode', type: 'text', nullable: true })
  boletoBarcode: string;

  @Column({ name: 'card_link_url', type: 'text', nullable: true })
  cardLinkUrl: string;

  @Column({ name: 'link_expires_at', type: 'timestamptz', nullable: true })
  linkExpiresAt: Date;

  @CreateDateColumn({ name: 'created_at' })
  createdAt: Date;

  @UpdateDateColumn({ name: 'updated_at' })
  updatedAt: Date;
}

// ============================================================
// DTOs
// ============================================================
import {
  IsString, IsNumber, IsOptional, IsIn, IsDateString,
  Min, IsUUID,
} from 'class-validator';
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';

export class ManualPaymentDto {
  @ApiProperty({ description: 'Valor pago', example: 1500.00 })
  @IsNumber()
  @Min(0)
  amountPaid: number;

  @ApiProperty({
    description: 'Método de pagamento',
    enum: ['pix', 'boleto', 'credit_card', 'manual', 'transfer'],
  })
  @IsIn(['pix', 'boleto', 'credit_card', 'manual', 'transfer'])
  paymentMethod: string;

  @ApiPropertyOptional({ description: 'Referência / comprovante / PIX txid' })
  @IsOptional()
  @IsString()
  paymentReference?: string;

  @ApiPropertyOptional({ description: 'Observações sobre o pagamento' })
  @IsOptional()
  @IsString()
  paymentNotes?: string;

  @ApiPropertyOptional({ description: 'Data do pagamento (default: agora)', example: '2025-05-01T10:00:00Z' })
  @IsOptional()
  @IsDateString()
  paidAt?: string;
}

export class GeneratePaymentLinkDto {
  @ApiProperty({
    description: 'Tipo de título a gerar',
    enum: ['pix', 'boleto', 'card'],
  })
  @IsIn(['pix', 'boleto', 'card'])
  method: 'pix' | 'boleto' | 'card';

  @ApiPropertyOptional({ description: 'Validade do título em horas (padrão 24)', example: 24 })
  @IsOptional()
  @IsNumber()
  @Min(1)
  expiresInHours?: number;
}

export class CreateChargeDto {
  @ApiPropertyOptional({ description: 'ID do contrato' })
  @IsOptional()
  @IsUUID()
  contractId?: string;

  @ApiProperty({ description: 'ID do cliente' })
  @IsUUID()
  clientId: string;

  @ApiProperty({ example: 'Mensalidade Maio/2025' })
  @IsString()
  description: string;

  @ApiProperty({ example: 1500.00 })
  @IsNumber()
  @Min(0)
  amount: number;

  @ApiPropertyOptional({ example: 0 })
  @IsOptional()
  @IsNumber()
  @Min(0)
  discount?: number;

  @ApiProperty({ example: '2025-05-05', description: 'Data de vencimento' })
  @IsDateString()
  dueDate: string;
}

// ============================================================
// GATEWAY INTERFACE (plug-in para Asaas/Iugu no futuro)
// ============================================================
export interface PaymentGateway {
  createCharge(params: {
    externalId: string;
    amount: number;
    dueDate: string;
    customerName: string;
    customerDocument: string;
    customerEmail: string;
    description: string;
  }): Promise<{ gatewayId: string; pixQrCode?: string; boletoUrl?: string; boletoCode?: string }>;

  getCharge(gatewayId: string): Promise<{ status: string; paidAt?: Date }>;
  cancelCharge(gatewayId: string): Promise<boolean>;
}

// Implementação nula — troca por Asaas/Iugu sem quebrar nada
export class NoopPaymentGateway implements PaymentGateway {
  async createCharge() { return { gatewayId: '', pixQrCode: undefined, boletoUrl: undefined }; }
  async getCharge() { return { status: 'pending' }; }
  async cancelCharge() { return true; }
}

// ============================================================
// SERVICE: PaymentsService
// ============================================================
import { Injectable, NotFoundException, BadRequestException, Logger } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository, DataSource } from 'typeorm';
import { Cron, CronExpression } from '@nestjs/schedule';
import { MailService } from '../email/email.module';
import { FiscalService } from '../fiscal/fiscal.module';
import { VapiService } from '../vapi/vapi.module';

@Injectable()
export class PaymentsService {
  private readonly logger = new Logger(PaymentsService.name);

  constructor(
    @InjectRepository(Charge)
    private readonly chargeRepo: Repository<Charge>,

    private readonly dataSource: DataSource,
    private readonly mailService: MailService,
    private readonly fiscalService: FiscalService,
    private readonly vapiService: VapiService,
  ) {}

  // ----------------------------------------------------------
  // Sequência de número de cobrança por tenant
  // ----------------------------------------------------------
  private async nextChargeNumber(tenantId: string): Promise<string> {
    const result = await this.dataSource.query(
      `INSERT INTO tenant_sequences (tenant_id, entity, last_value)
       VALUES ($1, 'charge', 1)
       ON CONFLICT (tenant_id, entity) DO UPDATE
       SET last_value = tenant_sequences.last_value + 1
       RETURNING last_value`,
      [tenantId],
    );
    const row = Array.isArray(result[0]) ? result[0][0] : result[0];
    const seq = String(row.last_value).padStart(5, '0');
    return `COB-${new Date().getFullYear()}-${seq}`;
  }

  async create(tenantId: string, userId: string, dto: CreateChargeDto): Promise<Charge> {
    const number = await this.nextChargeNumber(tenantId);
    const charge = this.chargeRepo.create({
      ...dto,
      tenantId,
      createdBy: userId,
      number,
      status: 'pending',
    });
    return this.chargeRepo.save(charge);
  }

  async findAll(tenantId: string, filters?: { status?: string; clientId?: string }): Promise<Charge[]> {
    const qb = this.chargeRepo.createQueryBuilder('c')
      .where('c.tenant_id = :tenantId', { tenantId })
      .orderBy('c.due_date', 'ASC');

    if (filters?.status) qb.andWhere('c.status = :status', { status: filters.status });
    if (filters?.clientId) qb.andWhere('c.client_id = :clientId', { clientId: filters.clientId });

    return qb.getMany();
  }

  async findOne(tenantId: string, id: string): Promise<Charge> {
    const charge = await this.chargeRepo.findOne({ where: { id, tenantId } });
    if (!charge) throw new NotFoundException('Cobrança não encontrada');
    return charge;
  }

  // ----------------------------------------------------------
  // BAIXA MANUAL DE PAGAMENTO
  // ----------------------------------------------------------
  async registerManualPayment(
    tenantId: string,
    chargeId: string,
    dto: ManualPaymentDto,
  ): Promise<Charge> {
    const charge = await this.findOne(tenantId, chargeId);

    if (charge.status === 'paid')
      throw new BadRequestException('Cobrança já está paga');
    if (charge.status === 'cancelled')
      throw new BadRequestException('Cobrança cancelada não pode receber baixa');

    const today = new Date();
    const dueDate = new Date(charge.dueDate + 'T12:00:00');
    let fine = 0;
    let interest = 0;

    if (today > dueDate) {
      const daysLate = Math.floor((today.getTime() - dueDate.getTime()) / 86400000);
      fine     = +(charge.amount * 0.02).toFixed(2);                   // 2% multa
      interest = +(charge.amount * 0.01 / 30 * daysLate).toFixed(2);  // 1% a.m. pro-rata
    }

    charge.amountPaid       = dto.amountPaid;
    charge.paymentMethod    = dto.paymentMethod;
    charge.paymentReference = dto.paymentReference;
    charge.paymentNotes     = dto.paymentNotes;
    charge.paidAt           = dto.paidAt ? new Date(dto.paidAt) : new Date();
    charge.fine             = fine;
    charge.interest         = interest;
    charge.status           = 'paid';

    const saved = await this.chargeRepo.save(charge);

    // Dispara NFS-e automática se o contrato tiver invoiceTrigger = 'after_payment'
    if (saved.contractId) {
      this.tryAutoInvoice(saved).catch((err) =>
        this.logger.warn(`Auto-invoice falhou para cobrança ${saved.id}: ${err.message}`),
      );
    }

    return saved;
  }

  // ----------------------------------------------------------
  // NFS-e automática — disparada por after_payment ou before_due
  // ----------------------------------------------------------
  private async tryAutoInvoice(charge: Charge, expectedTrigger = 'after_payment'): Promise<void> {
    // Evita emissão duplicada
    const existing = await this.dataSource.query(
      `SELECT id FROM invoices
       WHERE charge_id = $1 AND tenant_id = $2 AND status != 'cancelled'`,
      [charge.id, charge.tenantId],
    );
    if (existing.length) return;

    const rows = await this.dataSource.query(`
      SELECT
        co.invoice_trigger,
        t.document               AS provider_cnpj,
        t.municipal_inscription  AS provider_mi,
        t.nfse_url,
        t.simple_national,
        t.fiscal_incentive,
        t.nfse_certificate,
        t.nfse_certificate_pass,
        t.ibge_code              AS service_city_code,
        cl.document              AS borrower_doc,
        cl.name                  AS borrower_name,
        cl.municipal_inscription AS borrower_mi,
        cl.email_nf,
        cl.email,
        cl.address,
        cl.address_number,
        cl.address_complement,
        cl.neighborhood,
        cl.ibge_code             AS borrower_ibge,
        cl.uf,
        cl.zip_code,
        (SELECT ci.service_list_item FROM contract_items ci
         WHERE ci.contract_id = co.id AND ci.service_list_item IS NOT NULL
         LIMIT 1) AS service_list_item,
        (SELECT ci.iss_aliquota FROM contract_items ci
         WHERE ci.contract_id = co.id LIMIT 1) AS iss_aliquota,
        (SELECT ci.discrimination FROM contract_items ci
         WHERE ci.contract_id = co.id LIMIT 1) AS item_discrimination
      FROM contracts co
      JOIN tenants  t  ON t.id  = co.tenant_id
      JOIN clients  cl ON cl.id = co.client_id
      WHERE co.id = $1 AND co.tenant_id = $2
    `, [charge.contractId, charge.tenantId]);

    const r = rows[0];
    if (!r) return;
    if (r.invoice_trigger !== expectedTrigger) return;
    // Requisitos mínimos para emissão
    if (!r.nfse_url || !r.provider_cnpj || !r.provider_mi) return;
    if (!r.service_list_item) return;
    if (!r.borrower_doc) return;

    const today = new Date();
    const competenceDate = `${today.getFullYear()}-${String(today.getMonth() + 1).padStart(2, '0')}-01`;

    await this.fiscalService.issueInvoice({
      tenantId:                   charge.tenantId,
      chargeId:                   charge.id,
      contractId:                 charge.contractId,
      clientId:                   charge.clientId,
      providerCnpj:               r.provider_cnpj,
      providerMunicipalInscription: r.provider_mi,
      nfseUrl:                    r.nfse_url,
      simpleNational:             Boolean(r.simple_national),
      fiscalIncentive:            Boolean(r.fiscal_incentive),
      nfseCertificate:            r.nfse_certificate ?? undefined,
      nfseCertificatePass:        r.nfse_certificate_pass ?? undefined,
      serviceAmount:              Number(charge.amount),
      issAliquota:                Number(r.iss_aliquota ?? 0),
      issRetained:                false,
      serviceListItem:            r.service_list_item,
      discrimination:             charge.description ?? r.item_discrimination ?? '',
      serviceCityCode:            r.service_city_code,
      issExigibility:             1,
      competenceDate,
      borrowerDocument:           r.borrower_doc,
      borrowerName:               r.borrower_name,
      borrowerMunicipalInscription: r.borrower_mi ?? undefined,
      borrowerEmail:              r.email_nf ?? r.email ?? undefined,
      borrowerAddress:            r.address ?? undefined,
      borrowerAddressNumber:      r.address_number ?? undefined,
      borrowerAddressComplement:  r.address_complement ?? undefined,
      borrowerNeighborhood:       r.neighborhood ?? undefined,
      borrowerIbgeCode:           r.borrower_ibge ?? undefined,
      borrowerUf:                 r.uf ?? undefined,
      borrowerZipCode:            r.zip_code ?? undefined,
    });

    this.logger.log(`NFS-e enfileirada automaticamente — cobrança ${charge.id}`);
  }

  // ----------------------------------------------------------
  // GERAÇÃO DE TÍTULO VIA V.API
  // Cria um título (PIX / boleto / link de cartão) no v.api e
  // armazena os dados retornados (pix_copy_paste, boleto_url, etc.)
  // na cobrança para exibição no frontend.
  // ----------------------------------------------------------
  async generatePaymentLink(
    tenantId: string,
    chargeId: string,
    dto: GeneratePaymentLinkDto,
  ): Promise<Charge> {
    const charge = await this.findOne(tenantId, chargeId);

    if (charge.status === 'paid')
      throw new BadRequestException('Cobrança já está paga');
    if (charge.status === 'cancelled')
      throw new BadRequestException('Cobrança cancelada');

    // Busca API key do tenant + dados do cliente
    const rows = await this.dataSource.query(`
      SELECT
        t.vapi_api_key,
        t.vapi_pix_key,
        t.name AS tenant_name,
        t.ibge_code AS tenant_city,
        cl.name      AS client_name,
        cl.document  AS client_document,
        cl.email     AS client_email
      FROM tenants t
      JOIN clients cl ON cl.id = $1 AND cl.tenant_id = t.id
      WHERE t.id = $2
    `, [charge.clientId, tenantId]);

    const r = rows[0];
    if (!r?.vapi_api_key)
      throw new BadRequestException(
        'Chave de API do v.api não configurada. ' +
        'Acesse Configurações → Empresa → v.api para configurar.',
      );

    if (dto.method === 'pix' && !r.vapi_pix_key)
      throw new BadRequestException(
        'Chave PIX não configurada. ' +
        'Acesse Configurações → Empresa → v.api e informe sua Chave PIX.',
      );

    const result = await this.vapiService.createCharge({
      apiKey:         r.vapi_api_key,
      chargeType:     dto.method,
      amount:         Number(charge.amount),
      dueDate:        charge.dueDate,
      externalRef:    charge.number,
      payerName:      r.client_name,
      payerDocument:  r.client_document,
      payerEmail:     r.client_email,
      description:    charge.description ?? undefined,
      pixKey:         r.vapi_pix_key ?? undefined,
      merchantName:   r.tenant_name,
      expiresInHours: dto.expiresInHours,
    });

    charge.gateway         = 'vapi';
    charge.gatewayChargeId = result.id;
    charge.pixCopyPaste    = result.pixCopyPaste ?? null;
    charge.pixTxid         = result.pixTxid ?? null;
    charge.boletoUrl       = result.boletoUrl ?? null;
    charge.boletoBarcode   = result.boletoBarcode ?? null;
    charge.cardLinkUrl     = result.cardLinkUrl ?? null;
    if (result.expiresAt) charge.linkExpiresAt = new Date(result.expiresAt);

    return this.chargeRepo.save(charge);
  }

  async cancel(tenantId: string, id: string): Promise<Charge> {
    const charge = await this.findOne(tenantId, id);
    if (charge.status === 'paid')
      throw new BadRequestException('Não é possível cancelar cobrança paga');
    charge.status = 'cancelled';
    charge.cancelledAt = new Date();
    return this.chargeRepo.save(charge);
  }

  // ----------------------------------------------------------
  // CRON 1 — Marca cobranças vencidas (01:00)
  // ----------------------------------------------------------
  @Cron(CronExpression.EVERY_DAY_AT_1AM)
  async markOverdue(): Promise<void> {
    const result = await this.chargeRepo.createQueryBuilder()
      .update()
      .set({ status: 'overdue' })
      .where('status = :s', { s: 'pending' })
      .andWhere('due_date < CURRENT_DATE')
      .execute();

    if (result.affected) {
      this.logger.log(`markOverdue: ${result.affected} cobranças marcadas como vencidas`);
    }
  }

  // ----------------------------------------------------------
  // CRON 2 — Gera cobranças mensais dos contratos ativos (01:05)
  // Dispara no billingDay de cada contrato; se o mês tem menos dias
  // que billingDay, dispara no último dia do mês.
  // ----------------------------------------------------------
  @Cron('5 1 * * *')
  async generateContractCharges(): Promise<void> {
    const today = new Date();
    const todayDay    = today.getDate();
    const year        = today.getFullYear();
    const month       = today.getMonth() + 1;
    const lastDayOfMonth = new Date(year, month, 0).getDate();

    const contracts = await this.dataSource.query(`
      SELECT c.*
      FROM contracts c
      WHERE c.status = 'active'
        AND c.total > 0
        AND (c.end_date IS NULL OR c.end_date >= CURRENT_DATE)
        AND (
          c.billing_day = $1
          OR (c.billing_day > $2 AND $1 = $2)
        )
    `, [todayDay, lastDayOfMonth]);

    for (const contract of contracts) {
      // Evita duplicatas: verifica se já existe cobrança pendente/paga neste mês
      const existing = await this.dataSource.query(`
        SELECT id FROM charges
        WHERE contract_id = $1
          AND tenant_id   = $2
          AND status     != 'cancelled'
          AND DATE_TRUNC('month', created_at) = DATE_TRUNC('month', NOW())
      `, [contract.id, contract.tenant_id]);

      if (existing.length) continue;

      const paymentTerms = Math.max(Number(contract.payment_terms ?? 30), 1);
      const dueDate = new Date(today);
      dueDate.setDate(today.getDate() + paymentTerms);
      const dueDateStr = dueDate.toISOString().split('T')[0];

      const monthLabel = `${String(month).padStart(2, '0')}/${year}`;
      const title = contract.title ?? contract.number;

      const newCharge = await this.create(contract.tenant_id, 'system', {
        contractId:  contract.id,
        clientId:    contract.client_id,
        description: `${title} — ${monthLabel}`,
        amount:      Number(contract.total),
        dueDate:     dueDateStr,
      });

      this.logger.log(`Cobrança gerada: contrato ${contract.number} (${monthLabel})`);

      if (contract.invoice_trigger === 'before_due') {
        this.tryAutoInvoice(newCharge, 'before_due').catch((err) =>
          this.logger.warn(`Auto-invoice (before_due) falhou — contrato ${contract.number}: ${err.message}`),
        );
      }
    }
  }

  // ----------------------------------------------------------
  // CRON 3 — Notificações de vencimento por e-mail (08:00)
  // Envia: 7 dias antes, 1 dia antes e ao vencer (overdue).
  // Marca flag notified_Xd para não reenviar.
  // ----------------------------------------------------------
  @Cron(CronExpression.EVERY_DAY_AT_8AM)
  async sendPaymentNotifications(): Promise<void> {
    const fmtBRL  = (v: number) =>
      new Intl.NumberFormat('pt-BR', { style: 'currency', currency: 'BRL' }).format(v);
    const fmtDate = (d: string) =>
      new Date(d + 'T12:00:00').toLocaleDateString('pt-BR');

    // Batches: [daysAhead, dbField, overdue flag]
    type Batch = { daysAhead: number; field: string; overdue: boolean; label: string };
    const batches: Batch[] = [
      { daysAhead: 7, field: 'notified_7d',      overdue: false, label: 'vencimento em 7 dias' },
      { daysAhead: 1, field: 'notified_1d',       overdue: false, label: 'vencimento amanhã' },
      { daysAhead: 0, field: 'notified_overdue',  overdue: true,  label: 'em atraso' },
    ];

    for (const batch of batches) {
      const whereDate = batch.overdue
        ? `ch.status = 'overdue' AND ch.${batch.field} = false`
        : `ch.status IN ('pending','overdue')
           AND ch.due_date = (CURRENT_DATE + $1 * INTERVAL '1 day')::date
           AND ch.${batch.field} = false`;

      const params: any[] = batch.overdue ? [] : [batch.daysAhead];

      const rows: any[] = await this.dataSource.query(`
        SELECT
          ch.id, ch.amount, ch.description, ch.due_date,
          cl.name  AS client_name,
          cl.email AS client_email,
          t.name   AS tenant_name,
          t.id     AS tenant_id
        FROM charges ch
        JOIN clients cl ON cl.id = ch.client_id
        JOIN tenants t  ON t.id  = ch.tenant_id
        WHERE ${whereDate}
          AND cl.email IS NOT NULL
      `, params);

      for (const row of rows) {
        try {
          const isOverdue = batch.overdue;
          const accentColor = isOverdue ? '#DC2626' : '#0B3D91';
          const heading = isOverdue
            ? 'Cobrança em atraso'
            : batch.daysAhead === 1
              ? 'Sua cobrança vence amanhã'
              : `Sua cobrança vence em ${batch.daysAhead} dias`;

          const html = `
<!DOCTYPE html>
<html lang="pt-BR">
<head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1"></head>
<body style="margin:0;padding:0;background:#f4f4f5;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif">
  <table width="100%" cellpadding="0" cellspacing="0" style="background:#f4f4f5;padding:40px 0">
    <tr><td align="center">
      <table width="540" cellpadding="0" cellspacing="0" style="background:#fff;border-radius:12px;overflow:hidden;box-shadow:0 2px 8px rgba(0,0,0,.08)">
        <tr><td style="background:${accentColor};padding:24px 32px">
          <p style="margin:0;color:#fff;font-size:20px;font-weight:700">${row.tenant_name}</p>
          <p style="margin:4px 0 0;color:rgba(255,255,255,.75);font-size:13px">${heading}</p>
        </td></tr>
        <tr><td style="padding:28px 32px">
          <p style="margin:0 0 6px;font-size:15px;color:#111827">Olá, <strong>${row.client_name}</strong></p>
          <p style="margin:0 0 24px;font-size:14px;color:#6B7280">
            ${isOverdue
              ? 'Identificamos que a cobrança abaixo está em atraso. Por favor, regularize o pagamento o quanto antes.'
              : 'Você tem uma cobrança com vencimento próximo. Confira os detalhes abaixo.'}
          </p>
          <table width="100%" cellpadding="0" cellspacing="0" style="border:1px solid #E5E7EB;border-radius:8px;overflow:hidden;margin-bottom:24px">
            <tr style="background:#F9FAFB">
              <td style="padding:10px 16px;font-size:12px;font-weight:600;color:#6B7280;text-transform:uppercase;letter-spacing:.05em">Descrição</td>
              <td style="padding:10px 16px;font-size:12px;font-weight:600;color:#6B7280;text-transform:uppercase;letter-spacing:.05em">Valor</td>
              <td style="padding:10px 16px;font-size:12px;font-weight:600;color:#6B7280;text-transform:uppercase;letter-spacing:.05em">Vencimento</td>
            </tr>
            <tr>
              <td style="padding:12px 16px;font-size:14px;color:#374151">${row.description ?? 'Cobrança'}</td>
              <td style="padding:12px 16px;font-size:14px;font-weight:700;color:${accentColor}">${fmtBRL(Number(row.amount))}</td>
              <td style="padding:12px 16px;font-size:14px;color:#374151">${fmtDate(row.due_date)}</td>
            </tr>
          </table>
          <p style="margin:0;font-size:13px;color:#9CA3AF">Em caso de dúvidas, entre em contato com <strong>${row.tenant_name}</strong>.</p>
        </td></tr>
        <tr><td style="background:#F9FAFB;border-top:1px solid #E5E7EB;padding:14px 32px;text-align:center">
          <p style="margin:0;font-size:11px;color:#9CA3AF">Enviado via <strong style="color:#6B7280">Klavo</strong> · Plataforma de gestão financeira</p>
        </td></tr>
      </table>
    </td></tr>
  </table>
</body>
</html>`;

          await this.mailService.sendMail(row.tenant_id, {
            to: row.client_email,
            subject: `${heading} — ${fmtBRL(Number(row.amount))} — ${row.tenant_name}`,
            html,
          });

          await this.dataSource.query(
            `UPDATE charges SET ${batch.field} = true WHERE id = $1`,
            [row.id],
          );

          this.logger.log(`Notificação "${batch.label}" enviada para ${row.client_email} (cobrança ${row.id})`);
        } catch (err) {
          this.logger.warn(`Falha ao notificar cobrança ${row.id}: ${(err as Error).message}`);
        }
      }
    }
  }
}

// ============================================================
// CONTROLLER
// ============================================================
import {
  Controller, Get, Post, Patch,
  Param, Body, Query, HttpCode, HttpStatus, UseGuards, Request, Delete,
} from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport';
import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger';

@ApiTags('Cobranças')
@ApiBearerAuth()
@UseGuards(AuthGuard('jwt'))
@Controller('charges')
export class PaymentsController {
  constructor(private readonly paymentsService: PaymentsService) {}

  @Post()
  @ApiOperation({ summary: 'Criar cobrança avulsa' })
  create(@Body() dto: CreateChargeDto, @Request() req: any) {
    return this.paymentsService.create(req.user.tenantId, req.user.userId, dto);
  }

  @Get()
  @ApiOperation({ summary: 'Listar cobranças' })
  findAll(
    @Request() req: any,
    @Query('status') status?: string,
    @Query('clientId') clientId?: string,
  ) {
    return this.paymentsService.findAll(req.user.tenantId, { status, clientId });
  }

  @Get(':id')
  @ApiOperation({ summary: 'Detalhar cobrança' })
  findOne(@Param('id') id: string, @Request() req: any) {
    return this.paymentsService.findOne(req.user.tenantId, id);
  }

  @Patch(':id/pay')
  @ApiOperation({ summary: 'Registrar baixa manual de pagamento' })
  pay(@Param('id') id: string, @Body() dto: ManualPaymentDto, @Request() req: any) {
    return this.paymentsService.registerManualPayment(req.user.tenantId, id, dto);
  }

  @Post(':id/payment-link')
  @ApiOperation({
    summary: 'Gerar título de pagamento (PIX / boleto / cartão) via v.api',
    description:
      'Cria um título no v.api e retorna os dados de pagamento (pix_copy_paste, boleto_url, etc.).' +
      ' Requer vapi_api_key configurada nas Configurações do tenant.',
  })
  generatePaymentLink(
    @Param('id') id: string,
    @Body() dto: GeneratePaymentLinkDto,
    @Request() req: any,
  ) {
    return this.paymentsService.generatePaymentLink(req.user.tenantId, id, dto);
  }

  @Patch(':id/cancel')
  @HttpCode(HttpStatus.OK)
  @ApiOperation({ summary: 'Cancelar cobrança' })
  cancel(@Param('id') id: string, @Request() req: any) {
    return this.paymentsService.cancel(req.user.tenantId, id);
  }
}

// ============================================================
// MODULE
// ============================================================
import { Module, forwardRef } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { EmailModule } from '../email/email.module';
import { FiscalModule } from '../fiscal/fiscal.module';
import { VapiModule } from '../vapi/vapi.module';

@Module({
  imports: [
    TypeOrmModule.forFeature([Charge]),
    EmailModule,
    forwardRef(() => FiscalModule),
    VapiModule,
  ],
  controllers: [PaymentsController],
  providers: [PaymentsService],
  exports: [PaymentsService],
})
export class PaymentsModule {}
