# DESAFIOS.md

Log de fricções, armadilhas e conhecimento não-óbvio sobre a migração `felicidade_catia` (CI3) → `felicidade_catia2.0` (Laravel 12). Leitura obrigatória no início de toda sessão.

Cada entrada: o que é, por que importa, como não cair nessa de novo. Entradas resolvidas devem ser removidas (ou movidas para "Resolvidos" com data).

---

## [2026-08-31] `tb_movimento.ID_ACORDO_DIVIDA` é uma coluna NOVA em produção, não legada — adicionada via `ALTER TABLE` com aprovação do usuário

Ao construir o Acordo de Dívida (spec/acordo-de-divida.md), precisei vincular cada parcela (`Movimento`) ao acordo que a gerou (`tb_acordo_divida`) de forma confiável — o legado nunca precisou disso porque nunca implementou de verdade a edição/exclusão desse módulo. Diferente de `ID_RECORRENTE` (que já existia no schema real, confirmado via `SHOW COLUMNS`), `ID_ACORDO_DIVIDA` **não existia** em `tb_movimento` — perguntei ao usuário e, com aprovação explícita, rodei `ALTER TABLE tb_movimento ADD COLUMN ID_ACORDO_DIVIDA INT NULL` contra o MySQL real de produção (aditivo, nullable, não mexeu em dado existente).

**Como não cair nessa de novo**: todo o resto das migrations deste projeto segue o padrão "só espelha o que já existe" (`Schema::hasTable()` sempre pula em produção — nunca altera schema real). Esta foi a primeira exceção — ver também a entrada seguinte (`tb_dimob_declarante`, uma tabela nova inteira, não só uma coluna). Se outro módulo precisar de uma coluna/tabela genuinamente nova (não apenas mirror de algo legado), o mesmo cuidado se aplica: confirmar contra `SHOW COLUMNS`/`SHOW TABLES` real antes de assumir que já existe (não repetir o erro já cometido com `ID_RECORRENTE`, que foi assumido sem checar e só por sorte já existia), e pedir aprovação explícita antes de rodar DDL contra o banco compartilhado com o CI3 em produção. O script rodado está registrado em `database/sql/2026_08_31_add_id_acordo_divida_to_tb_movimento.sql`.

---

## [2026-09-01] `tb_dimob_declarante` é uma tabela NOVA inteira — ainda não criada em produção, aguardando aprovação

Ao construir o Relatório de DIMOB (spec/relatorio-dimob.md), o arquivo de submissão oficial à Receita Federal precisa dos dados da própria imobiliária (CNPJ, Nome Empresarial, CPF do responsável perante à RFB, endereço) — dado que **não existe em nenhuma tabela do legado nem do Laravel** (confirmado: nenhuma tabela `*empresa*`/`*config*`/`*parametro*` no MySQL real). Criei `tb_dimob_declarante` do zero (não é mirror de nada) para guardar esse registro único.

A migration (`database/migrations/2026_09_01_000001_create_tb_dimob_declarante_table.php`) cria a tabela automaticamente da próxima vez que `php artisan migrate` rodar — inclusive em produção, já que não tem guard `Schema::hasTable()` (diferente do resto do projeto). Também deixei o DDL equivalente em `database/sql/2026_09_01_create_tb_dimob_declarante.sql`, pro caso de a via escolhida pra aplicar em produção não ser `artisan migrate` direto.

**Como não cair nessa de novo**: até o momento em que este parágrafo foi escrito, `tb_dimob_declarante` **ainda não existia no MySQL real de produção** — só foi criada no SQLite de teste (via `RefreshDatabase`) e não há confirmação de que `php artisan migrate` já rodou em produção depois desta mudança. Antes de considerar o Bloco 2 do Relatório de DIMOB (arquivo de submissão) utilizável de verdade, confirme com `SHOW TABLES LIKE 'tb_dimob_declarante'` se ela existe em produção — se não existir, peça aprovação explícita do usuário antes de rodar a migration ou o `.sql` contra o banco compartilhado com o CI3.

---

## [Resolvido 2026-09-01] Submenu Financeiro do sidebar não abria sozinho na rota `contas.confirmacao`

`$emFinanceiro` em `resources/views/partials/sidebar.blade.php` não incluía o padrão `contas.confirmacao`, então navegar pra aba "Contas a Pagar/Receber" dentro de Confirmação de Lançamento colapsava o submenu inteiro (o link continuava ativo no markup, mas ficava invisível com o submenu fechado). Corrigido adicionando `'contas.confirmacao'` à lista de `$emFinanceiro`.

---

## [2026-08-31] PENDENTE: 4 testes falham perto da virada de mês por causa de `Carbon::addMonth()` em datas de fim de mês (achado durante o build de Lançamento Rápido, fora de escopo dessa spec)

Rodando a suíte completa em 31/08/2026, 4 testes falham só por causa da data do dia: `ContratoManagementTest::cadastro_gera_600_meses_de_lancamento_de_aluguel`, `ContaReceberTest::aluguel_automatico_aparece_na_grade_mas_nao_e_editavel` e dois em `MovimentoImovelTest` (`listagem_de_lancamentos_expoe_a_operacao...`/`listagem_de_lancamentos_filtra_por_mes_de_vencimento`). Todos esperam que "o mês que vem" a partir de hoje seja `09/2026`, mas o código de produção (`ContratoController`, e o setup desses testes) usa `now()->addMonth()`/equivalente — e `Carbon::parse('2026-08-31')->addMonth()` dá `2026-10-01`, não `2026-09-30` (Carbon não faz clamp em dia-de-fim-de-mês por padrão: 31 de agosto + 1 mês "estoura" pro dia 31 de setembro, que não existe, e vira 1º de outubro). O teste foi escrito assumindo `addMonth()` ingênuo sem considerar essa borda.

**Não é regressão desta sessão** — confirmado rodando a suíte antes e depois das mudanças de `spec/lancamento-rapido.md` (Concerns `ResolveOperacaoDeEvento`/`AlinhaVencimentoAoAluguel` extraídos de `MovimentoController`/`ContaReceberController`/`RecorrenteController`): as mesmas 4 falhas ocorrem em ambos os casos, e nenhuma delas tem relação com os arquivos tocados nesta spec.

**Como não cair nessa de novo**: ao escrever teste que dependa de "o mês que vem" a partir de `now()`, ou usar `Carbon::create()`/`now()->startOfMonth()->addMonth()` (que não estoura) em vez de `now()->addMonth()` direto quando o dia-base pode ser 29/30/31, ou rodar a suíte fixando a data com `Carbon::setTestNow()` num dia sem essa ambiguidade (ex. dia 10). Fora do escopo consertar isso agora — é comportamento pré-existente, não tocado por nenhuma spec até aqui.

---

## [2026-09-03] PENDENTE: `ContratoReajusteController::index()` quebra com 500 se `mes_referencia` vier inválido na URL (achado durante o build de spec/dashboard.md, fora de escopo dessa spec)

Ao construir o Painel (spec/dashboard.md), criei `App\Support\MesReferencia::resolver()` pra validar o filtro `mes_referencia` antes de repassar pra `Carbon::createFromFormat('Y-m', ...)` — sem isso, um valor inválido (`?mes_referencia=lixo`, por exemplo) faz o `createFromFormat` lançar `InvalidArgumentException` sem captura, resultando em 500 (com stack trace se `APP_DEBUG=true`). Apliquei o fix no `DashboardController` e no novo `RelatorioInadimplenteController`, mas **`ContratoReajusteController::index()` tem exatamente o mesmo problema** (`$mesReferencia = $request->string('mes_referencia')->toString() ?: now()->format('Y-m');` só cobre string vazia, não string inválida) — pré-existente, não foi tocado pela minha spec além de extrair a lógica de elegibilidade pra `App\Support\ReajusteElegibilidade` (que preserva o comportamento original, incluindo esse bug).

**Como não cair nessa de novo**: trocar a linha de resolução de `mes_referencia` em `ContratoReajusteController::index()` por `MesReferencia::resolver($request->string('mes_referencia')->toString())` (mesma classe já usada pelo Painel/Relatório de Inadimplentes) — mudança pequena e isolada, mas fora do escopo de `spec/dashboard.md`, então não fiz agora.

---

## [Resolvido 2026-09-01] `tb_movimento` legado (Pagar/Receber) tinha `flag_status_movimento=1` em quase todos os registros manuais antigos — Alterar/Excluir ficavam travados

Confirmado contra o banco real: em Contas a Pagar, 687 dos 690 lançamentos ativos (99,6%) tinham `flag=1` (só 3 com `flag=2`) — o legado (CI3) nunca gravou `flag=2` nesse balde, só o Laravel passou a fazer isso nesta migração.

Em Contas a Receber a primeira tentativa de correção usou `id_contrato IS NULL` como discriminador do aluguel automático — **errado**: uma segunda rodada (`SELECT id_evento, desc_evento, COUNT(*) FROM tb_movimento WHERE id_tipo_movimento=3 AND flag_status_movimento=1 AND id_contrato IS NOT NULL GROUP BY id_evento`) mostrou que o CI3 vinculava `id_contrato` em praticamente todo lançamento do imóvel — IPTU PARCELADO (2288), CONDOMÍNIO (2440), IRRF RETIDO PELA LOCATÁRIA (455), e dezenas de outros eventos manuais — não só no aluguel (`id_evento=1`, 668 mil registros). Usar `id_contrato IS NULL` deixava esses milhares de lançamentos manuais legítimos presos sem Alterar/Excluir, exatamente como antes da correção.

**Corrigido**: `ContaPagarController::pertenceAoModulo()` não filtra mais por flag (não existe nada em Contas a Pagar gerado automaticamente vinculado a contrato). A segunda tentativa em Contas a Receber usou `id_evento != Evento::ALUGUEL` como discriminador (mais preciso que `id_contrato`, que o CI3 vinculava em quase todo lançamento do imóvel, não só no aluguel) — mas o usuário pediu mais uma volta: **decisão final (2026-09-01)**, `ContaReceberController::pertenceAoModulo()` não filtra mais por nada além do tipo — Alterar/Excluir ficam liberados também na própria linha de aluguel automático (`id_evento=ALUGUEL`, gerada pelo `ContratoController`), não só nos lançamentos manuais. Ciente do risco: editar/excluir o aluguel por aqui não avisa o Contrato, então pode ficar fora de sincronia com o que ele gerou/espera pros próximos meses (reajuste, rescisão etc. dependem desses registros) — aceito explicitamente pelo usuário.

**Como não cair nessa de novo**: `id_contrato` referencia o contrato do imóvel de forma ampla no legado (praticamente todo lançamento do imóvel carrega essa FK) — nunca é um sinal confiável de "gerado automaticamente"; `id_evento` é mais preciso, mas mesmo esse discriminador foi removido por decisão explícita do usuário. Se um dia isso causar uma dessincronia real com o Contrato (valor/data do aluguel de um mês já gerado divergindo do que o Contrato esperava), é comportamento esperado da decisão tomada aqui, não um bug — não reverter sem confirmar de novo com o usuário.

---

## [2026-08-27] DECISÃO: módulo Fechamento (dia/mês fechado) não será migrado

O legado (`Fechamento_dia`/`Fechamento_listar`/`Fechamento_reabrir`) tem um conceito de "período fechado" que bloqueava baixa em Contas a Pagar/Receber (`Contas_pagar_baixa`/`Contas_receber_baixa`) numa data já fechada. Decisão do usuário (2026-08-27): esse módulo não vai ser portado — não é pendência, é escopo definitivamente fora do sistema novo.

**Como não cair nessa de novo**: não abrir spec nem tarefa pra portar Fechamento, e não tratar a ausência dessa validação em `ContaPagarController`/`ContaReceberController` (ver `spec/contas-a-pagar-e-receber.md`) como lacuna a corrigir — é o comportamento definitivo.

---

## [2026-08-26] Lookup com ID fixo precisa de `$incrementing = false` + PK em `$fillable`, senão `create(['id_x' => N, ...])` ignora o ID informado

Ao corrigir um teste de `ContratoManagementTest` que precisava seedar `tb_status_contrato` com um ID específico (`StatusContrato::CONTRATO = 5`), descobri que `StatusContrato::create(['id_status_contrato' => 5, 'desc_status_contrato' => 'X'])` **não gravava com id=5** — a chave primária não estava em `$fillable`, então o Eloquent silenciosamente ignorava o valor informado e deixava o auto-increment assumir (a 2ª linha inserida numa tabela nova vira id=2, não 5). Um `StatusContrato::where('id_status_contrato', 5)->update(...)` logo depois não encontrava nenhuma linha.

**Inofensivo em produção** — a tabela real (`tb_status_contrato`) já existe com os IDs corretos, e o código só lê por `WHERE id_status_contrato = 5` (join do model `Imovel::statusContrato()`), nunca insere via Eloquent. O problema só aparece em testes/seeds que precisam recriar um lookup fixo com IDs específicos numa tabela nova (sqlite `:memory:`).

**Como não cair nessa de novo**: todo lookup fixo cujos testes precisem seedar um ID semântico específico (padrão já usado em `PrazoLocacao`: `id_prazo_locacao` em `$fillable` + `public $incrementing = false;`) precisa do mesmo tratamento. `StatusContrato` foi corrigido (`app/Models/StatusContrato.php`) — ao criar um teste novo que precise pinar o ID de outro lookup fixo (`TipoFianca`, `LocalMovimento`, `TipoLocal`, `Reajuste`, etc.), checar primeiro se o model aceita `id_x` em `create()` antes de assumir que funciona; se não aceitar e o teste precisar de um ID exato, aplicar o mesmo padrão.

---

## [2026-08-25] `APP_URL` errado no `.env` local quebrava a foto (imagem quebrada) — e corrigi-lo quebrou 222 testes (ambos corrigidos)

Depois do fix de cache de imagem (entrada abaixo), o usuário reportou foto ainda quebrada (ícone de imagem quebrada no navegador). Investigando: o ambiente real do usuário serve a aplicação via Apache/XAMPP numa subpasta — `http://localhost/felicidade_catia2.0/public/...` —, mas `.env` tinha `APP_URL=http://127.0.0.1:8000` (valor de exemplo do template, nunca ajustado pro ambiente real). `Storage::disk('public')->url(...)` monta a URL a partir de `APP_URL`, então toda foto (e qualquer link gerado por `Storage::url()`) apontava pro host/porta errado — nunca funcionou, mesmo antes do bug do form aninhado; só ficou visível agora que o Salvar passou a funcionar de verdade.

**Corrigido** `.env` local: `APP_URL=http://localhost/felicidade_catia2.0/public` (bate com a pasta real do XAMPP).

**Efeito colateral descoberto ao corrigir**: isso quebrou 222 dos 248 testes na hora. Causa: `Illuminate\Foundation\Testing\Concerns\MakesHttpRequests::prepareUrlForRequest()` monta a URL de toda chamada de teste (`$this->get('/dashboard')` etc.) via `url($uri)` — que também usa `config('app.url')`. Com um `APP_URL` contendo um caminho (`/felicidade_catia2.0/public`), toda chamada de teste virou `.../felicidade_catia2.0/public/dashboard`, que não bate com nenhuma rota registrada (`/dashboard`) → 404 em cascata.

**Corrigido**: adicionado `<env name="APP_URL" value="http://localhost"/>` em `phpunit.xml` (mesmo padrão já usado ali pra `DB_CONNECTION`/`CACHE_STORE`/etc. — variáveis de ambiente forçadas pro ambiente de teste, que sempre vencem o que estiver no `.env` local).

**Como não cair nessa de novo**: `APP_URL` no `.env` de qualquer ambiente precisa bater com a URL real de acesso (incluindo subpasta, se o servidor publicar o projeto assim, comum em XAMPP/Apache sem vhost dedicado) — não é só cosmético, afeta toda URL absoluta gerada por `url()`/`route()`/`Storage::url()`. E qualquer mudança em `APP_URL` precisa ser conferida contra a suíte de testes, porque o helper de teste do Laravel monta as URLs de requisição a partir dele — se um dia o projeto ganhar múltiplos ambientes de desenvolvimento reais (não só este XAMPP), considerar um `.env.testing` dedicado em vez de só sobrescrever no `phpunit.xml`.

---

## [2026-08-25] Foto de usuário salvava certinho, mas navegador mostrava a antiga (cache de imagem, corrigido)

Depois do fix do `<form>` aninhado (entrada abaixo), o usuário reportou "consertou mas não tá salvando a foto". Investigando: o arquivo físico (`storage/app/public/usuarios/usuario_1.jpg`) e a coluna `foto` no banco estavam corretos e atualizados — o salvamento sempre funcionou. O problema é que `UserController`/`ProfileController::salvarFoto()` sempre grava com o MESMO nome determinístico (`usuario_{id}.{extensao}`), então trocar a foto produz a mesma URL de antes — o navegador serve a versão em cache em vez de buscar a nova, dando a impressão de "não salvou".

**Corrigido**: `sidebar.blade.php` e `partials/campos-pessoais.blade.php` agora anexam `?v={{ Storage::disk('public')->lastModified(...) }}` na URL da foto, forçando o navegador a buscar de novo sempre que o arquivo mudar de fato. Aproveitado pra também blindar contra `foto` referenciando um arquivo que não existe mais no disco (dado legado pode ter esse tipo de inconsistência) — a view checa `Storage::disk('public')->exists(...)` antes de montar a URL/chamar `lastModified()`, caindo no círculo com a inicial do nome em vez de estourar exceção. Teste de regressão: `ProfileTest::test_cabecalho_cai_no_circulo_com_inicial_quando_foto_referenciada_nao_existe_no_disco`.

**Como não cair nessa de novo**: sempre que uma URL de arquivo enviado pelo usuário for determinística (nome fixo, não um hash/uuid por upload), qualquer `<img>`/link pra ela precisa de cache-busting (query string com timestamp/hash) — senão trocar o conteúdo não reflete visualmente sem um hard-refresh, o que parece (e é reportado como) "não salvou".

---

## [2026-08-25] `<form>` aninhado no popup de lookup fechava o form da página cedo demais — botão Salvar parava de funcionar (corrigido)

Reportado pelo usuário: "alteração de usuario e de perfil botão salvar não ta funcionando" — clicar em Salvar não fazia nada, página não mudava, sem erro visível. Backend e testes (`UserManagementTest` 22/22, `ProfileTest` 14/14) passavam normalmente — o bug era 100% de parsing HTML no navegador, invisível a qualquer teste que use `->post()`/`->put()` diretamente.

**Causa**: `partials/select-com-lookup.blade.php` (usado por Gênero, Estado Civil, Nacionalidade, Naturalidade, Órgão Emissor — em TODO formulário que tem esses campos) tinha um `<form>` de verdade dentro do popup "+" de CRUD do lookup, e esse popup fica dentro do `<form>` principal da página. HTML5 não permite `<form>` aninhado: o navegador **ignora a tag de abertura** do form interno (não cria elemento novo), mas processa a tag de fechamento `</form>` correspondente contra o ponteiro de formulário — que ainda aponta pro form EXTERNO, porque nunca foi reatribuído. Resultado: a primeira ocorrência do popup já fecha o form da página inteira cedo demais, e todo elemento depois dela no HTML (outros campos, e principalmente o botão Salvar no fim da página) fica órfão, fora do form real — um `<button type="submit">` sem form associado não faz nada ao ser clicado.

Isso afetava **todo formulário que usa `select-com-lookup`** (Usuário, Perfil, Locador, Proponente, Fiador, Procurador, Beneficiário, Imóvel — confirmado via grep, 10 arquivos), não só Usuário/Perfil; só apareceu como bug reportado nessas duas telas porque são formulários simples de página única (o Salvar aparece imediatamente após o popup problemático), enquanto os wizards de várias etapas talvez ainda não tivessem sido testados até o fim (clicar Salvar na última etapa) nesta sessão.

**Corrigido**: o `<form>` do popup virou um `<div>` — a lógica de salvar (`lookup-modal.js::salvar()`) já lia de `x-model="descricaoForm"` (estado reativo do Alpine), nunca de `FormData`, então o `<form>` nativo era só decorativo (usado para `@submit.prevent` e Enter-para-enviar). O botão virou `type="button" @click="salvar()"` e o input ganhou `@keydown.enter.prevent="salvar()"` pra preservar o Enter-envia.

**Testes de regressão**: como PHPUnit não faz parsing de HTML de verdade, os testes novos (`UserManagementTest::test_tela_de_edicao_nao_tem_form_aninhado_antes_do_botao_salvar`, `ProfileTest::test_tela_de_perfil_nao_tem_form_aninhado_antes_do_botao_salvar`) checam diretamente o HTML renderizado: nenhum `<form` deve aparecer entre a abertura do form principal e o botão Salvar. Confirmado que ambos falham contra a versão com bug (revertida temporariamente) antes de passar contra a correção.

**Como não cair nessa de novo**: nunca colocar um `<form>` HTML dentro de outro `<form>` — nem "escondido" atrás de um modal/popup que fica fechado por padrão (`x-show` não remove do DOM, só esconde via CSS; o parsing acontece igual no carregamento da página). Se um popup precisa de "enviar com Enter" ou de agrupar campos, usar um `<div>` com `@keydown.enter.prevent` no lugar de `<form>`.

---

## [2026-08-25] Rateio de Imóvel: "Código" e "Percentual" não são a mesma coisa — apagava dado real (corrigido)

Na spec original (Requisito 23), assumi que Código e Percentual dos 5 blocos de rateio (`tb_rateio`) só faziam sentido quando "Rateio = SIM", então `ImovelController::salvarRateio()` zerava os dois campos sempre que o rateio estivesse "Não". **Errado**: conferido contra dado real de produção, 102 dos 136 imóveis reais têm `codigo_prefeitura` preenchido (ex. `"0730677-2"`) mesmo com `rateio_prefeitura=2` (Não) — o mesmo padrão se repete em `bombeiro` (107/139) e `cedae`. Código é o número de ligação/inscrição da concessionária (existe independente de haver rateio de custo ou não); só o Percentual é de fato exclusivo de quando o rateio está ativo (confirmado: percentual só vem preenchido em 2-4 casos quando rateio=Não, praticamente nunca).

**Efeito prático do bug**: o formulário escondia o campo Código atrás do mesmo toggle "Rateio = Sim/Não" que escondia o Percentual, e o controller gravava `null` no Código sempre que o rateio estivesse "Não" — ou seja, **editar e salvar qualquer imóvel real que já tivesse rateio="Não" com um código de concessionária preenchido apagaria esse código silenciosamente**, mesmo que o usuário não tivesse tocado naquele campo. Achado pelo usuário testando no navegador antes de qualquer edição real ter sido salva — sem perda de dado em produção.

**Corrigido**: Código agora sempre visível/editável no formulário e sempre gravado como veio, independente do valor de "Rateio"; só Percentual continua condicionado a `rateio = Sim`. Testes de regressão em `ImovelManagementTest::test_rateio_nao_ainda_permite_salvar_o_codigo` e `test_editar_imovel_com_codigo_e_rateio_nao_preserva_o_codigo`.

**Como não cair nessa de novo**: ao escrever "só faz sentido preencher X quando Y" numa spec sem checar o dado real, isso é uma suposição, não um fato — sempre validar contra uma amostra real de produção (contagem de quantas linhas têm X preenchido cruzado com o valor de Y) antes de decidir gate de visibilidade/persistência condicional entre dois campos que parecem relacionados mas podem representar conceitos diferentes.

---

## [2026-08-25] Atributo `required` do HTML sobrevive a uma mudança de obrigatório→opcional no backend (corrigido, Imóvel)

Ao corrigir `codigo_imovel` de obrigatório pra opcional em `ImovelRequest` (spec/imovel.md, correção pós-`/spec-review`), o `<input>` correspondente em `imoveis/_form.blade.php` continuou com o atributo HTML `required` — sobrou de quando o campo era obrigatório. Como o wizard (`form-wizard.js::proxima()`) bloqueia "Próximo" chamando `checkValidity()` nos campos visíveis da etapa atual, o navegador impedia avançar mesmo com o servidor já aceitando o campo vazio. **Nenhum teste automatizado pegou isso** — `php artisan test` faz requisições HTTP diretas (`->post()`/`->put()`), que pulam completamente a validação nativa do navegador; só apareceu quando o usuário testou de verdade no browser. Corrigido removendo o `required` do input.

**Como não cair nessa de novo**: sempre que um campo mudar de obrigatório pra opcional (ou vice-versa) na validação do servidor, checar também o atributo HTML `required`/`novalidate` do campo correspondente na view — são dois lugares que precisam ficar sincronizados manualmente, e só um deles (o do servidor) é coberto pelos testes PHPUnit deste projeto.

---

## [2026-08-24] PENDENTE: trava de "nome obrigatório" em Sócio está na direção oposta de Cônjuge

Em `LocadorRequest::regrasSocio()` (copiada verbatim pra `ProponenteRequest`), a regra é `"{$chave}.cpf" => ['required_with:{$chave}.nome', ...]` — ou seja, **CPF fica obrigatório se NOME for preenchido**, não o contrário. Isso significa que dá pra submeter um sócio só com CPF (ou só com telefone, etc.) sem nome, e a validação não barra — o controller (`sincronizarSocio()`) só silenciosamente descarta esse sócio (`empty($dadosSocio['nome'])` → não salva nada, sem erro pro usuário).

Isso é **diferente** da trava de Cônjuge (`'conjuge.nome' => ['required_with:conjuge.cpf,conjuge.rg', ...]`), que é a direção "certa": nome fica obrigatório se CPF/RG forem preenchidos, bloqueando com erro visível em vez de descartar silenciosamente.

**Não é um bug introduzido agora** — já existia em `LocadorRequest` antes da tarefa do formulário por etapas, só foi copiado pra `ProponenteRequest` sem alterar. Descoberto ao testar o wizard (esperava um erro de validação em `socio1.nome` e não veio). Fora do escopo da spec atual (`formulario-por-etapas`) — não corrigido agora. `FiadorRequest::regrasSocio()` (spec/fiadores.md) copiou o mesmo padrão de `ProponenteRequest` de propósito (consistência entre os 3 módulos) — herda a mesma direção invertida, não corrigido por estar fora do escopo daquela spec também.

**Como não cair nessa de novo**: se for mexer em `regrasSocio()`/`regrasContaBancaria` no futuro, considerar inverter a trava pra `nome` ficar obrigatório quando CPF/RG forem preenchidos (mesmo padrão de cônjuge), e decidir se o comportamento atual de "descartar silenciosamente" é aceitável ou se devia virar erro visível — perguntar ao usuário antes de mudar, é uma decisão de UX/produto.

---

## [2026-08-24] PENDENTE: `Proponente` está fora dos arrays `em_uso` de `LookupController::CONFIG`

Ao construir o módulo de Fiador, percebido que `LookupController::CONFIG` (usado pelo botão "+" pra impedir excluir um item de lookup que está em uso) tem `em_uso` cobrindo `User`/`Locador`/`Socio`/`Conjuge` pros lookups `genero`/`estado-civil`/`nacionalidade`/`naturalidade`/`rg-emissao`/`profissao`, mas **não `Proponente`**, mesmo `ProponenteRequest` usando todos esses lookups no cadastro. `Fiador` foi adicionado corretamente nesta sessão (junto com `FiadorProfissional` em `fonte-outras-renda`), mas `Proponente` continua faltando — não é um bug introduzido agora, já existia desde o módulo de Proponentes.

**Efeito prático**: um Administrador consegue excluir (via popup) um item de Gênero/Estado Civil/Nacionalidade/Naturalidade/Órgão Emissor/Profissão que está em uso por algum Proponente, e o `id_sexo`/`id_estado_civil`/etc. daquele proponente fica "orfão" (aponta pra um `id_status` já excluído do lookup) sem erro nenhum na hora da exclusão.

**Como não cair nessa de novo**: ao mexer em `LookupController::CONFIG` no futuro, adicionar `[Proponente::class, 'coluna']` nos `em_uso` dos 6 lookups listados acima (mesmo padrão que `Fiador`/`Locador` já seguem). Fora do escopo da spec de Fiador — não corrigido agora.

---

## [2026-08-22] Popup de CRUD de lookup quebrou o script de mostrar/esconder seções do Locador (corrigido)

Ao migrar os `<select>` de Estado Civil/Gênero/Nacionalidade/Naturalidade/Órgão emissor/Profissão pro partial `partials/select-com-lookup.blade.php` (feature do botão "+"), o `<select>` novo passou a renderizar só `name`, sem `id`. O script inline no fim de `locadores/_form.blade.php` (que já existia ANTES dessa feature, controla PF/PJ, cônjuge e conta bancária/PIX) faz `document.getElementById('id_estado_civil').addEventListener(...)` — sem o `id`, essa chamada retorna `null`, `.addEventListener` em `null` lança `TypeError` de forma síncrona, e como as chamadas seguintes (`atualizarSecoes()`, `atualizarRepasse()`) estão no mesmo bloco de execução, TODO o resto do script parou de rodar. Sintoma em produção: seção de cônjuge sempre visível (mesmo pra solteiro) e conta bancária/PIX nunca reagiam à seleção de forma de repasse — reportado pelo usuário como "página mal carregada, sócio/cônjuge tudo habilitado, meio de pagamento não importa o que seleciona sempre aparece conta bancária".

**Corrigido**: `select-com-lookup.blade.php` agora renderiza `id="{{ $name }}"` (mais `label for="{{ $name }}"`) no `<select>`, restaurando a mesma âncora que o script já esperava. Teste de regressão em `LocadorManagementTest::test_pagina_de_cadastro_mantem_o_id_que_o_script_de_secoes_depende` (falha sem o `id`, passa com ele).

**Como não cair nessa de novo**: ver a regra nova em `CLAUDE.md` (Gate 1) — ao alterar Blade de uma página que já tem JS/comportamento existente, testar o FLUXO INTEIRO da página depois (não só a feature nova), porque PHPUnit não executa JavaScript e não acusa esse tipo de regressão sozinho.

---

## [2026-08-22] Popup de lookup 404 sempre: `fetch('/lookups/...')` hardcoded quebra em deploy de subpasta (corrigido)

Erro separado do de cima, também no popup de CRUD de lookup: `lookup-modal.js` montava a URL do `fetch` como `` `/lookups/${tipo}` `` — caminho absoluto a partir da RAIZ DO DOMÍNIO. Funciona perfeitamente em `php artisan serve` (app na raiz), mas o usuário testa via Apache/XAMPP com a aplicação numa subpasta: `http://localhost/felicidade_catia2.0/public/...`. Nesse cenário, `/lookups/genero` aponta pra `http://localhost/lookups/genero` (que não existe — a rota só existe dentro de `/felicidade_catia2.0/public/`), então **todo** fetch do popup dava 404, em qualquer campo, mesmo o resto da página funcionando normalmente (forms/links usam `route()`/`action="{{ route(...) }}"`, que já resolve a subpasta certo automaticamente a partir da requisição real).

**Como identificado**: usuário reportou "tá dando em todos" (não um campo específico) e mandou a URL exata que estava usando (`http://localhost/felicidade_catia2.0/public/locadores/create`) — isso denunciou o deploy em subpasta na hora.

**Corrigido**: `select-com-lookup.blade.php` passa a gerar a URL correta no servidor via `route('lookups.index', ['tipo' => $tipo])` (mesmo mecanismo que já funcionava pros forms) e entrega pronta pro componente Alpine (`urlBase` — 5º argumento de `selectLookup()`); `lookup-modal.js` usa `this.urlBase`/`this.urlItem(id)` em vez de montar a URL sozinho. Teste de regressão: `UserManagementTest::test_select_de_lookup_usa_url_gerada_pelo_route_em_vez_de_caminho_fixo` (falha sem o fix, passa com ele).

**Como não cair nessa de novo**: nunca montar URL de API em JS concatenando um caminho fixo a partir de `/`. Sempre gerar a URL no Blade via `route()`/`url()` (que já resolve a subpasta real a partir da requisição) e passar pronta pro JS — nunca assumir que a aplicação está servida na raiz do domínio.

---

## [2026-08-21] PENDENTE: reestruturar o dashboard (indicadores/gráficos) — só depois que a maior parte dos módulos existir

Hoje `resources/views/dashboard.blade.php` mostra os módulos futuros como uma grade de cards "Em breve" que funciona mais como menu/roadmap do que como painel de indicadores. O usuário confirmou que isso é intencional por enquanto: o dashboard vai ganhar indicadores/gráficos de verdade (usuários ativos, financeiro, contratos, etc.), mas **só faz sentido reestruturar quando a maioria dos módulos de negócio já existir** — antes disso os indicadores seriam vazios/inventados.

**Não fazer isso módulo a módulo.** Quando chegar a hora (usuário vai pedir explicitamente), revisar o layout inteiro de uma vez: separar em seções (indicadores/stat tiles no topo, área de gráfico, acesso rápido aos módulos), usando dado real disponível em cada módulo já migrado até ali — consultar a skill `dataviz` pra desenho de stat tile/gráfico antes de implementar.

---

## [2026-08-21] `APP_URL` sem porta quebrava a URL de qualquer arquivo do disco `public` (corrigido)

`APP_URL=http://localhost` no `.env`, mas `php artisan serve` roda em `http://127.0.0.1:8000`. `Storage::disk('public')->url(...)` monta a URL a partir de `APP_URL` (`config/filesystems.php`), então toda foto de usuário (upload no módulo de Usuários ou no Meu Perfil) gerava um `<img src="http://localhost/storage/...">` sem a porta — o upload em si salvava certo no disco e no banco, só a exibição ficava quebrada, dando a impressão de "upload não funciona". Corrigido: `APP_URL=http://127.0.0.1:8000` no `.env` e no `.env.example`.

**Como não cair nessa de novo**: sempre que `php artisan serve` mudar de porta/host, `APP_URL` precisa acompanhar — não é só cosmético, quebra silenciosamente qualquer URL gerada a partir do disco `public` (fotos, futuros anexos/documentos).

---

## [2026-08-21] Ciclo de desenvolvimento alinhado ao crm_impactus: skills de processo + tratamento de sessão/CSRF

O `CLAUDE.md` deste projeto citava a skill `secure-coding` sem ela existir (nenhuma pasta `.claude/skills/` aqui) — corrigido portando 4 skills do `crm_impactus`, adaptadas (removido conteúdo específico de multi-tenant, que não se aplica aqui): `secure-coding` (+ `references/owasp-top-10.md`, cópia quase verbatim, é genérico), `unit-tests`, `test-coverage`, `qa-procedures`. **Use-as de verdade** ao revisar/escrever código — não é só documentação de prosa.

**Tratamento de sessão/CSRF expirada (419)**, também replicado do `crm_impactus`:
- `bootstrap/app.php` tem um handler de `HttpException` com `getStatusCode() === 419` (tipar como `TokenMismatchException` NÃO funciona no Laravel 11/12 — a exceção já foi convertida antes do callback rodar). Comportamento: NÃO desloga — formulário tradicional volta com `redirect()->back()->withInput()` (exceto `_token`/`senha`) e mensagem `session('error')`; AJAX/JSON recebe 419 com `{message, reload:true}`.
- Heartbeat em JS (`resources/views/layouts/app.blade.php`, no `<head>`) renova o token via `GET /refresh-csrf` (rota pública, fora do `auth`, devolve `{token, authenticated}`) a cada 15 min, mas **só se houve atividade real** (`pointerdown`/`keydown`/`scroll`) — e também ao voltar pra aba (`visibilitychange`). `SESSION_LIFETIME=480` (8h) combinado com isso.
- **Toda tela nova com formulário precisa incluir `@include('partials.flash')`** (mostra `session('status')`, `session('error')` e `$errors->first()`) — senão a mensagem de sessão expirada não aparece nela, só o `old()` dos campos é restaurado silenciosamente. Não existia isso ainda quando a tela de login foi escrita na sessão anterior; foi extraído pra partial nesta sessão.
- Sem teste PHPUnit automatizado pra isso — o Laravel desliga verificação de CSRF em ambiente de teste (`VerifyCsrfToken::runningUnitTests()`), então foi validado manualmente via `curl` com `_token` inválido + `Referer` simulado (mesmo padrão que o próprio crm_impactus usa, documentado como QA manual, não teste automatizado).
- `bootstrap/app.php` também audita todo 403 de usuário autenticado num `$exceptions->render()` só (`ACESSO NEGADO - <método> <path>` em `tb_log`) — não repita isso em controller novo.
- "Manter conectado" foi removido do login (o crm_impactus também não tem — nunca teve UI, era resíduo de código). Sessão longa + heartbeat resolve o problema que o remember-me tentaria resolver.

---

## [2026-08-21] Quatro bugs reais de login encontrados testando com credencial de produção (corrigidos)

Ao testar o login com uma credencial real (mesma usada hoje no sistema do cliente), apareceram quatro problemas em sequência — nenhum teórico, cada um só apareceu depois de corrigir o anterior:

1. **`senha` nula quebrava o login com erro 500**, em vez de negar a credencial normalmente. A coluna `SENHA` é nullable no schema real; `User::getAuthPassword()` tinha assinatura `: string` e retornava `$this->senha` direto — se o usuário buscado tem `senha` NULL, o PHP lança `TypeError` antes de chegar no `Hash::check`. Corrigido em `app/Models/User.php`: `return $this->senha ?? '';`. Teste: `test_usuario_sem_senha_definida_nao_autentica_nem_quebra` em `LoginTest`.

2. **O middleware global `TrimStrings` do Laravel removia espaços da senha antes do `Hash::check`.** A lista padrão de campos "nunca trim" do framework só cobre os nomes em inglês (`password`, `password_confirmation`, `current_password`) — este projeto usa `senha` (português), fora dessa lista. Corrigido em `bootstrap/app.php`: `$middleware->trimStrings(except: ['senha'])`. Teste: `test_senha_com_espaco_nas_pontas_nao_e_trimada_antes_da_verificacao` (confirmado que falha sem o fix).

3. **A causa mais séria, e a mais escondida: as colunas reais em `tb_usuario` (e em qualquer tabela legada) são MAIÚSCULAS** (`ID_USUARIO`, `NOME`, `LOGIN`, `SENHA`, `ID_STATUS`...), confirmado via `SHOW COLUMNS`. O Eloquent, sem `select()` explícito, sempre gera `SELECT *` — e o MySQL devolve as colunas no case exato definido na tabela quando a query usa `*` (só devolve no case *escrito na query* quando você nomeia as colunas explicitamente). Resultado: todo model hidratava com chaves `ID_USUARIO`/`NOME`/... e qualquer `$model->id_usuario`, `$model->senha`, `$model->id_status` (minúsculo, como o código inteiro usa) vinha **`null`** — não só no login, em QUALQUER model do banco real. Isso não aparecia nos testes porque o SQLite local usa as migrations-espelho, que criamos com nomes minúsculos. Diagnosticado comparando `SELECT *` vs `SELECT id_usuario` cru (o segundo devolve a chave minúscula, exatamente como escrita na query). **Corrigido globalmente** em `config/database.php`: `PDO::ATTR_CASE => PDO::CASE_LOWER` no array `options` das conexões `mysql`/`mariadb` — nunca remover essa linha.

4. **Depois de corrigir o #3, apareceu `RuntimeException: This password does not use the Bcrypt algorithm.`** O Laravel 12 tem uma checagem de segurança nova (`hashing.bcrypt.verify`, default `true` via `env('HASH_VERIFY', true)` no config padrão do framework — não precisa de `config/hashing.php` publicado pra valer) que usa `password_get_info()` do PHP pra confirmar que o hash é bcrypt "de verdade" antes de chamar `password_verify()`. Só que `password_get_info()` só reconhece hash `$2y$` como `algoName === 'bcrypt'` — hash `$2a$`/`$2b$` (como os gerados pela lib phpass do CI3) voltam `algoName: 'unknown'`, e o Laravel rejeita ANTES de sequer chamar `password_verify()` (que aceitaria o `$2a$` sem problema — a rejeição é só dessa checagem extra do Laravel, não do PHP). **Corrigido** com `HASH_VERIFY=false` no `.env` (e documentado em `.env.example`).

**Como não cair nessa de novo**: (a) qualquer campo cujo nome não seja em inglês precisa ser checado contra as listas de exceção hardcoded dos middlewares globais de transformação de request; (b) **todo teste que envolve autenticação/consulta a dado real precisa, cedo ou tarde, ser validado contra o MySQL de verdade, não só contra o SQLite dos testes** — o bug #3 é justamente o tipo de coisa que nenhum teste unitário pega, porque o ambiente de teste usa um schema que a gente mesmo escreveu (minúsculo); (c) ao adicionar um Model novo para uma tabela legada, não assumir que `$model->coluna` funciona só porque os testes passam — testar pelo menos uma vez contra o banco real.

---

## [2026-08-21] Campos de texto herdados: legado força maiúsculas via JS global (replicar ao migrar tela)

O CI3 tem um comportamento visual em TODAS as telas internas (pós-login): qualquer `input[type=text]` vira maiúsculo automaticamente enquanto a pessoa digita, **exceto** campos de e-mail (`txt_email`, `txt_email_conjuge`, `txt_email_socio`, `txt_email_socio2`), que viram minúsculo. É JS puro, sem CSS `text-transform` e sem sanitização no backend PHP:

```js
// assets/js/util.js (e util2.js, cópia idêntica) — delegado ao document, roda em qualquer tela que inclua home.php/home2.php
$(document).on('keyup', "input[type=text]", function () {
    $(this).val(function (_, val) { return val.toUpperCase(); });
});
$(document).on('blur', "input[type=text]", function () {
    $(this).val(function (_, val) {
        return (this.id=="txt_email"||this.id=="txt_email_conjuge"||this.id=="txt_email_socio"||this.id=="txt_email_socio2")
            ? val.toLowerCase() : val.toUpperCase();
    });
});
```

A tela de login é um caso à parte, com handler específico só pro campo de login em `assets/js/inicio.js` (`#txt_usuario`, mesmo `.toUpperCase()`, senha não é afetada).

**Prova de que isso é real, não só visual**: os dois usuários reais em produção têm `NOME` gravado inteiramente em maiúsculas ("CÁTIA FERREIRA", "LARISSA ZENAIDE ALVES SILVA DE CARVALHO") e `LOGIN` também ("ADMIN", "LARISSA") — o valor que chega no banco é mesmo o maiusculizado, não é só estilo de exibição.

**Como aplicar em cada módulo novo**: ao construir um formulário, replicar esse comportamento no campo Laravel equivalente — maiúsculo por padrão, minúsculo só pra e-mail, mantendo senha (e campos claramente não-textuais) de fora. Fazer isso em DOIS lugares (diferente do legado, que só faz client-side): JS no campo (paridade de UX) **e** normalização no `FormRequest` do servidor (`prepareForValidation()` ou similar), pra não depender só de JS e pra manter consistência com o dado já gravado (buscas/ordenação por nome, por exemplo, dependem de tudo estar no mesmo case). Módulo de Usuários (`app/Http/Requests/UserRequest.php`) é a referência desse padrão.

---

## [2026-08-21] PENDENTE: controle de permissão granular por nível — atravessa todos os módulos, só depois que todos existirem

Hoje o sistema tem só dois níveis (`tb_nivel`: Administrador e Usuário/Funcionário), e qualquer usuário autenticado enxerga tudo que o próprio nível tem acesso de rota — sem controle fino de "esse Funcionário pode ver Financeiro mas não Contratos", por exemplo. Isso é uma necessidade real do negócio (confirmado com o usuário na spec de Usuários), mas foi **propositalmente adiado**: só faz sentido desenhar esse controle quando os módulos que ele vai restringir já existirem, senão vira abstração especulativa sem uso real pra validar o modelo.

**Não é responsabilidade de nenhum módulo específico** — é uma camada transversal (autorização) que vai precisar tocar rotas/controllers de TODOS os módulos já migrados até o momento em que for implementada. Ao planejar esse trabalho (spec futura, provavelmente `spec/permissoes.md`): revisar todo `routes/web.php` e todo controller com middleware `admin`/`auth` já existente até ali, não só adicionar um recurso novo.

**Como não cair nessa**: ao migrar um módulo novo, não assumir que "Funcionário vê tudo que não é exclusivo de Administrador" é definitivo — é o comportamento provisório até essa camada existir. Não implementar controle de permissão parcial/ad-hoc num módulo isolado antes dessa spec — geraria padrões inconsistentes entre módulos.

---

## [2026-08-21] MySQL local: `mysqli` do CLI falha com `auth_gssapi_client`, mas Laravel/PDO conecta normal

Tentar inspecionar o banco `felicidade_imobiliando` via `php -r 'new mysqli(...)'` no CLI falha com `The server requested authentication method unknown to the client [auth_gssapi_client]`. **Isso é limitação do cliente `mysqli` do PHP CLI, não do banco**: o Laravel (driver PDO mysql) conecta sem problema com as credenciais corretas (root / senha `1234` no `.env`, não versionado). Conexão e schema já validados.

**Para ler o schema real**, use `php artisan tinker --execute='...DB::select("SHOW COLUMNS FROM tb_x")...'` (via PDO), não `mysqli` no CLI. Feito nesta sessão para tb_usuario/tb_nivel/tb_status/tb_acesso_erro/tb_log; as migrations-espelho foram reconciliadas.

**Atenção que permanece**: imprimir dados de usuários reais (ex. hashes) via tinker é BLOQUEADO pelo classificador de segurança do ambiente — leia só schema/contagens, nunca linhas de dado pessoal. E a migration-espelho ainda é um SUBCONJUNTO das colunas (só o que cada módulo usa); `tb_usuario` real tem ~15 colunas NOT NULL de dados pessoais que só entram quando o módulo de Usuários for migrado.

---

## [2026-08-21] Senhas legadas são bcrypt padrão — compatíveis com o Laravel sem re-hash

A lib de senha do CI (`application/libraries/Bcrypt.php`) é phpass. Em PHP moderno (`CRYPT_BLOWFISH == 1`, sempre verdadeiro em 8.2), ela gera hash bcrypt padrão `$2a$08$...`, que `Hash::check()` do Laravel (driver bcrypt) verifica direto. **Não re-hashar senhas na migração** — o login existente funciona apontando o Model `User` para `tb_usuario` com `getAuthPasswordName() => 'senha'`.

Ressalva teórica: usuários muito antigos poderiam ter hash portable `$P$...` (fallback do phpass quando CRYPT_BLOWFISH indisponível), que NÃO verifica com bcrypt do Laravel. Improvável na prática (produção sempre teve blowfish), mas se aparecer um usuário que não loga com a senha certa, é o candidato. Solução seria re-hash no primeiro login bem-sucedido — não implementado porque não há evidência de que exista algum.

---

## [2026-08-21] Estratégia de migrations com banco compartilhado: espelho guardado por `hasTable`

Como o Laravel compartilha o banco de produção com o CI (coexistência), rodar `php artisan migrate` contra produção NÃO pode tentar recriar/dropar as tabelas legadas. Padrão adotado: cada migration-espelho começa com `if (Schema::hasTable('tb_x')) return;` no `up()` e tem `down()` vazio (nunca dropa tabela legada). Assim ela só age em ambiente local/teste onde a tabela não existe, e é inócua em produção. As migrations padrão do Laravel que criam `users`/`sessions`/`cache`/`jobs` foram **removidas** — usamos drivers em arquivo (`SESSION_DRIVER=file`, `CACHE_STORE=file`, `QUEUE_CONNECTION=sync`) para não exigir tabelas de infraestrutura no banco legado.
