diff --git a/.env.example b/.env.example index 1769c19..060002f 100644 --- a/.env.example +++ b/.env.example @@ -29,6 +29,12 @@ BACKUP_DIR= BACKUP_KEEP_DAYS= MYSQLDUMP_BIN= +# e-mail transacional (recuperacao de senha, confirmacao de e-mail). Vazio = nenhum e-mail sai e o log avisa +# (o provedor de producao ainda nao foi escolhido). Em desenvolvimento use MAIL_DRIVER=log: cada e-mail vira uma +# linha json em MAIL_LOG_FILE (vazio = storage/mail.log), com os links. Nunca use "log" em producao. +MAIL_DRIVER= +MAIL_LOG_FILE= + # so para o docker compose: senha do root do MySQL do container e porta publicada no host (vazio = 8080). # No compose, DB_PASS acima e a senha do usuario "fidelityx" do banco e nao pode ficar vazia. DB_ROOT_PASS= diff --git a/CLAUDE.md b/CLAUDE.md index 362032d..1599541 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -16,11 +16,16 @@ composer dump-autoload # obrigatorio apos criar classe nova em s php -S localhost:8000 -t public/ # servidor local (document root = public/) find src views public tests bin -name "*.php" -exec php -l {} \; # checagem de sintaxe composer test # phpunit (unidade + integração + fluxo); recria o banco fidelityx_test +composer analyse # phpstan nível 5 (phpstan.neon) com baseline; erro novo quebra +composer audit # dependências com falha de segurança conhecida composer test -- --testsuite Unit # só os testes sem banco node tests/js/masks.test.js # testes das mascaras (public/js/masks.js), sem navegador +node tests/js/forms.test.js # testes do public/js/forms.js (envio e campo com erro), sem navegador +node tests/js/customer.test.js # conta do valor da compra do public/js/customer.js (igual ao Money::toCents) tsc # compila src/ts -> public/js (tsconfig.json) php bin/migrate.php # roda as migrations pendentes (status | baseline NNN) php bin/backup.php # backup do banco (mysqldump .sql.gz em BACKUP_DIR) +php bin/create-admin.php email "Nome" # cria um administrador (senha pela entrada padrão) docker compose up -d --build # aplicacao + mysql em docker (docs/operacao.md) ``` @@ -28,9 +33,11 @@ Banco: `mysql -u root -p < database/schema.sql` em instalação nova. Banco já **Testes (PHPUnit 10.5)** em `tests/`: `Unit` (validators e classes de `src/Support`, sem banco), `Integration` (models, `RateLimiter`, `Migrator` e backup contra o banco) e `Feature` (fluxo completo por HTTP: o teste sobe um `php -S` próprio numa porta livre e usa cookie + `_csrf` como o navegador). O bootstrap **apaga e recria** o banco `fidelityx_test` a partir do `schema.sql` (o `phpunit.xml` força esse nome e o bootstrap recusa nome que não termine em `_test`); credenciais vêm do `.env` local ou das variáveis de ambiente no CI. Cada teste começa com as tabelas vazias (`DatabaseTestCase`). Regra nova de negócio ou bug corrigido = teste junto. O GitHub Actions (`.github/workflows/ci.yml`) roda lint + PHPUnit + `tests/js/masks.test.js` em PHP 8.1 e 8.3 com MySQL 8 em todo PR e push na `main`, **sem `.env`** (só variáveis de ambiente, como em produção), e um job à parte recompila o `masks.ts` e falha se o `masks.js` versionado não bater. O MySQL do XAMPP roda sem `sql_mode` estrito (trunca texto longo sem erro), o do CI é estrito: não escreva teste que dependa disso. +**Análise estática** (task 51): PHPStan nível 5 em `src`, `views`, `bin` e `public`, com `phpstan-baseline.neon` guardando o que já existia (hoje só os "might not be defined" das views, que recebem as variáveis pelo `extract()`). Código novo não entra na baseline: corrija o erro. Variável nova numa view ou erro antigo corrigido: `composer analyse -- --generate-baseline`. Rodar no CI, `composer audit` no workflow e Dependabot ainda dependem de mudar o `.github/` (pendente). + ## Arquitetura -**Roteamento** — tudo entra por `public/index.php` com `?url=dominio/acao`. Um `switch` no domínio (`merchant`, `customer`, mais `home` e `privacy` sem ação) instancia o controller e um `match` na ação chama o método `render*()`. Rota nova = novo braço no `match` + método no controller. O `index.php` abre a conexão com o banco antes de rotear, então sem MySQL (ou sem as variáveis obrigatórias do banco) toda rota devolve 503. +**Roteamento** — tudo entra por `public/index.php` com `?url=dominio/acao`. Um `switch` no domínio (`merchant`, `customer`, `admin`, mais `home` e `privacy` sem ação) instancia o controller e um `match` na ação chama o método `render*()`. Rota nova = novo braço no `match` + método no controller. A exceção é `?url=health` (`App\Support\Health`, task 56): responde antes da sessão, só `ok`/`erro` em JSON, conferindo o banco. O `index.php` abre a conexão com o banco antes de rotear, então sem MySQL (ou sem as variáveis obrigatórias do banco) toda rota devolve 503. **Configuração** — `App\Support\Env::load()` (chamado pelo `index.php`, pelo bootstrap dos testes e pelos scripts de `bin/`) põe tudo no `$_ENV`: variável de ambiente real primeiro, e o `.env`, se existir, só completa o que falta (`safeLoad`: a aplicação sobe sem o arquivo). Variável nova entra em `Env::KEYS` e no `.env.example`; leia com `Env::get()` ou `$_ENV`, nunca com `getenv()` direto. @@ -42,13 +49,17 @@ Banco: `mysql -u root -p < database/schema.sql` em instalação nova. Banco já **Fuso horário** — `app_timezone()` (`APP_TIMEZONE` do `.env`, padrão `America/Sao_Paulo`) é aplicado no PHP pelo `index.php` e pelo `tests/bootstrap.php`, e o `Database` faz `SET time_zone` com o mesmo offset em toda conexão. Não dependa do fuso do `php.ini` nem do servidor MySQL. -**Sessão do lojista** — o `authGuard` confere a cada requisição se a conta ainda existe e está `active` (desativada encerra a sessão na hora, com `conta_inativa`), expira a sessão parada há mais de `SessionGuard::IDLE_SECONDS` (8 h) e atualiza nome da loja e `last_seen`. O login guarda na sessão `password_sig` (`SessionGuard::passwordSignature`) e o `authGuard` compara com a senha do banco: senha trocada derruba as sessões abertas com a antiga. Quem altera `password_hash` precisa atualizar o `password_sig` da própria sessão (ver `handlePasswordChange`), senão se desloga. +**Sessão do lojista** — o `authGuard` confere a cada requisição se a conta ainda existe e está `active` (desativada encerra a sessão na hora, com `conta_inativa`), expira a sessão parada há mais de `SessionGuard::IDLE_SECONDS` (8 h) e atualiza nome da loja e `last_seen`. O login guarda na sessão `password_sig` (`SessionGuard::passwordSignature`) e o `authGuard` compara com a senha do banco: senha trocada derruba as sessões abertas com a antiga. Quem altera `password_hash` precisa atualizar o `password_sig` da própria sessão (ver `handlePasswordChange`), senão se desloga. Conta com `merchants.email_verified_at` vazio (cadastro novo, task 50) entra, mas o `authGuard` manda toda rota para `merchant/confirm-email` (reenvia o link, 3/h); só essa tela chama `authGuard(true)`. O link `merchant/verify-email&token=...` (`EmailVerificationModel`, 24 h, uma vez) não exige login. Os links por e-mail (confirmação e nova senha) herdam de `MerchantTokenModel`. -**Balcão** — a home do painel (`merchant/dashboard`) busca o cliente pelo telefone e mostra os 5 últimos atendidos (só cartão com movimentação). Na tela do cliente (`merchant/customer`) o lançamento é em pontos ou, se a loja tem regra (`merchants.points_rule_cents`, configurada em `merchant/points-rule`), pelo valor da compra: `Money::toCents` lê o valor digitado e `Money::pointsFor` converte arredondando para baixo. Depois de lançar, a tela mostra o novo saldo com **Desfazer** por `UNDO_SECONDS` (30 s; o aviso só aparece se o lançamento tiver até `UNDO_BANNER_SECONDS`). O estorno (`action=reverse`, do Desfazer ou do extrato `merchant/statement`) vale por `LoyaltyCardModel::REVERSAL_WINDOW_HOURS` (24 h), uma vez por lançamento e só se o saldo atual cobrir os pontos. Listas longas (clientes, extrato, relatórios) paginam com `App\Support\Paginator` + `views/partials/pagination.php`, 20 por página. +**Balcão** — a home do painel (`merchant/dashboard`) busca o cliente pelo telefone e mostra os 5 últimos atendidos (só cartão com movimentação). Na tela do cliente (`merchant/customer`) o lançamento é em pontos ou, se a loja tem regra (`merchants.points_rule_cents`, configurada em `merchant/points-rule`), pelo valor da compra: `Money::toCents` lê o valor digitado e `Money::pointsFor` converte arredondando para baixo. Depois de lançar, a tela mostra o novo saldo com **Desfazer** por `UNDO_SECONDS` (30 s; o aviso só aparece se o lançamento tiver até `UNDO_BANNER_SECONDS`). O estorno (`action=reverse`, do Desfazer ou do extrato `merchant/statement`) vale por `LoyaltyCardModel::REVERSAL_WINDOW_HOURS` (24 h) e uma vez por movimentação: lançamento só se o saldo atual cobrir os pontos; resgate (task 44, só pelo extrato) devolve os pontos ao saldo sem mexer no total acumulado e deixa de contar nos relatórios. A tela do cliente também corrige o nome e move o cartão para um telefone novo (`LoyaltyCardModel::rename` / `changePhone`, task 43): só o cartão desta loja muda, telefone que já tem cartão nesta loja é recusado e o número antigo some quando não sobra cartão dele. Listas longas (clientes, extrato, relatórios) paginam com `App\Support\Paginator` + `views/partials/pagination.php`, 20 por página. Os relatórios filtram por período (`App\Support\Period`: hoje, 7 dias, mês, intervalo; o saldo em circulação é sempre o atual) e `merchant/export` baixa CSV do histórico do período ou da lista de clientes (`App\Support\Csv`: `;` + BOM, texto que começa com `=`/`+`/`-`/`@` ganha `'` contra injeção de fórmula), sempre só desta loja (task 59). **Perfil** — `merchant/profile` tem dois formulários na mesma rota: dados da loja (padrão) e troca de senha (`action=password`, exige a senha atual, com limite de erros no bucket `password_change`, e a nova não pode ser igual à atual). Cadastro e troca seguem `App\Support\PasswordPolicy`: de 8 a 72 **bytes** (o limite do bcrypt); o login não confere tamanho, então senha antiga fora da regra continua entrando. E-mail e CPF/CNPJ aparecem só para leitura e nunca são lidos do POST. A sessão usa `use_strict_mode` (id inventado não é aceito), `cookie_secure` quando a requisição é HTTPS e `gc_maxlifetime` igual ao limite de inatividade. -**Proteções de toda requisição** — o `index.php` chama `RequestGuard` antes de rotear: cabeçalhos de segurança (`X-Frame-Options: DENY`, `frame-ancestors 'none'`, `nosniff`, `Referrer-Policy: same-origin`, sem `X-Powered-By`), `Cache-Control: no-store` em toda rota `merchant/*` e `customer/*` (`RequestGuard::sendNoStore`, depois do `session_start`) e parâmetros em formato de lista (`campo[]=`) viram texto vazio, então nenhum `(string)` de `$_GET`/`$_POST` gera warning. O logout é POST com CSRF (o "Sair" do menu é um formulário). Os testes de fluxo rodam com `display_errors` ligado (`tests/Support/router.php`): qualquer warning aparece no HTML e quebra o teste. +**Administração** (task 30) — `admin/login` e `admin/dashboard` (`AdminController`): tabela `admins` própria, conta criada só pela linha de comando (`php bin/create-admin.php email "Nome"`, senha pela entrada padrão). Sessão separada da do lojista (`admin_id`, `admin_sig`, expira com 1 h parada) e o mesmo limite de tentativas do login do lojista (buckets `admin_login_*`). O admin vê as lojas (dados da loja e quantos clientes têm, nunca os clientes) e ativa/desativa uma loja (`MerchantModel::setStatus`); desativada perde o acesso na hora. + +**Esqueci a senha** (task 21) — `merchant/forgot` manda por e-mail um link `merchant/reset&token=...` (`PasswordResetModel`, sobre o `MerchantTokenModel`: token de 32 bytes aleatórios, só o SHA-256 no banco, vale `TTL_SECONDS` = 1 h e uma vez; pedir outro cancela o anterior). A resposta do pedido é sempre a mesma, exista o e-mail ou não; limite de 10 pedidos/h por IP e 3/h por e-mail. Trocar a senha pelo link derruba as sessões abertas (mesmo `password_sig`). + +**Proteções de toda requisição** — o `index.php` chama `RequestGuard` antes de rotear: cabeçalhos de segurança (`X-Frame-Options: DENY`, CSP `RequestGuard::CSP` com `default-src 'self'` e sem nada inline, HSTS em HTTPS via `RequestGuard::sendHsts` depois do `Env::load`, `nosniff`, `Referrer-Policy: same-origin`, sem `X-Powered-By`), `Cache-Control: no-store` em toda rota `merchant/*` e `customer/*` (`RequestGuard::sendNoStore`, depois do `session_start`) e parâmetros em formato de lista (`campo[]=`) viram texto vazio, então nenhum `(string)` de `$_GET`/`$_POST` gera warning. O logout é POST com CSRF (o "Sair" do menu é um formulário). Os testes de fluxo rodam com `display_errors` ligado (`tests/Support/router.php`): qualquer warning aparece no HTML e quebra o teste. **Erros** — `ErrorController::handle($code)` renderiza `views/errors/{code}.php` (fallback `default.php`) e entrega às views o botão de voltar (`$backUrl`, `$backLabel`): lojista logado volta ao painel, qualquer outra pessoa à home pública; página de erro nova usa essas variáveis, nunca link fixo. Exceções não tratadas e erros fatais são logados e viram 500 pelo `App\Support\ErrorLog` (registrado no `index.php`): uma linha por erro com um código curto, que a página 500 também mostra. Do contexto da requisição o log leva só método e rota, nunca query string, corpo ou argumentos (têm telefone e senha). Detalhes técnicos vão só para `error_log`, cujo destino é `LOG_FILE` ou o do `php.ini`. @@ -57,12 +68,14 @@ Banco: `mysql -u root -p < database/schema.sql` em instalação nova. Banco já - `loyalty_cards` = par (merchant, customer) único. Guarda o que o cliente deu **a esta loja** (`customer_name`, `consent_at`, `consent_version`), o saldo (`current_points`, `total_accumulated`) e `anonymized_at`. `findOrCreate` usa `ON DUPLICATE KEY UPDATE id = LAST_INSERT_ID(id)` e nunca troca nome nem consentimento de cartão existente. - **LGPD (regra da task 11, `docs/adr/002-lgpd-dados-por-loja.md`)**: nenhuma tela de uma loja mostra dado vindo de outra. Telefone sem cartão nesta loja vai sempre para o cadastro rápido, seja novo ou de outra loja: nunca crie caminho que diferencie os dois. Consentimento é gravado no cadastro com `Privacy::VERSION` (mudou o texto de `views/privacy.php` de forma relevante, troque a versão). Exclusão = `LoyaltyCardModel::anonymize` (cartão fica só para os relatórios, telefone some quando não há mais cartão). - `points_log` é o histórico (`earn`/`redeem`/`reversal`, `reward_id` nos resgates, `reverses_id` nos estornos). Toda mudança de saldo passa por `LoyaltyCardModel::addPoints` / `redeem` / `reverse`, que atualizam o cartão e gravam o log na mesma transação; `redeem` usa `SELECT ... FOR UPDATE` para impedir gasto duplo. Não altere saldo fora desses métodos. -- `merchants` guarda CPF **ou** CNPJ (normalizados, UNIQUE). Validação só por dígito verificador, sem API externa (ver `docs/adr/001-documents-validation.md`). +- `merchants` guarda CPF **ou** CNPJ (normalizados por `DocumentValidator::normalize`, UNIQUE). O CNPJ pode ser alfanumérico (letras maiúsculas nas 12 primeiras posições, task 57). Validação só por dígito verificador, sem API externa (ver `docs/adr/001-documents-validation.md`). **Área pública** — a home (`HomeController`, view `views/home.php`, `public/css/home.css`) apresenta o produto e leva ao cadastro; lojista logado vai direto ao painel. `customer/balance&loja=CODIGO` consulta o saldo pelo telefone sem login, **só na loja do código** (`merchants.public_code`, impresso no cartaz e embutido no QR), com limite por IP (5/min → 429) e exibindo só o primeiro nome do cartão daquela loja. Sem código válido, a página pede o código (nunca lista lojas). `privacy` é a política de privacidade (rascunho pendente de revisão jurídica). **Limite de tentativas** — `App\Support\RateLimiter` conta tentativas na tabela `rate_limit_hits` (chave guardada só como hash SHA-256), então o limite sobrevive a apagar o cookie. Usos: login (em 15 min, 5 erros do mesmo e-mail vindos do mesmo IP ou 20 erros do mesmo IP em qualquer conta → 429), troca de senha (5 senhas atuais erradas por conta) e consulta pública (5/min por IP). **O login nunca bloqueia só pelo e-mail**: isso deixaria qualquer pessoa trancar a dona da conta errando a senha de propósito (task 38). O bucket `login_account` (por conta, somando todos os IPs) só gera aviso no log ao chegar em `LoginGuard::MAX_FAILURES_PER_ACCOUNT` (50); o CAPTCHA que deve entrar aí ainda não foi escolhido (task 45). E-mail inexistente roda `password_verify` contra `LoginGuard::DUMMY_HASH` (mesmo tempo da senha errada), e o login gera token CSRF novo (`Csrf::regenerate`). Nos buckets a chave do IP é `RateLimiter::clientKey()`, que em IPv6 vale pela rede /64 (um assinante tem o bloco inteiro e trocaria de endereço a cada tentativa). O endereço exato vem de `RateLimiter::clientIp()` (`App\Support\ClientIp`): é o `REMOTE_ADDR`, e o `X-Forwarded-For` só vale quando a requisição chega de um proxy listado em `TRUSTED_PROXIES` (o mesmo vale para o `X-Forwarded-Proto` em `RequestGuard::isHttps()`). Nunca leia esses cabeçalhos direto. +**E-mail** (task 55) — envie sempre por `App\Mail\MailerFactory::fromEnv()->send(new Message(...))`, nunca com `mail()` direto. `MAIL_DRIVER` vazio = `NullMailer` (nada sai, o log registra só assunto e hash do destinatário); `log` = `LogMailer`, que grava o e-mail inteiro em `MAIL_LOG_FILE` (dev e testes: o `HttpTestCase` liga e o teste lê com `sentMails()`). O provedor de produção ainda não foi escolhido. + **Migrations** — `App\Support\Migrator` + `bin/migrate.php` registram em `schema_migrations` o nome de cada arquivo aplicado (o controle é pelo nome, não pelo maior número: migration de outro PR com número menor ainda roda). Migration nova = arquivo `NNN_descricao.sql` + a mesma mudança no `schema.sql` + o nome no `INSERT INTO schema_migrations` do fim do `schema.sql` (o `MigratorTest` cobra). **Produção** — `Dockerfile` + `docker-compose.yml` (Apache, PHP 8.3, MySQL 8; `docker/php.ini` desliga `display_errors`), backup por `bin/backup.php` (`App\Support\Backup`) e o guia em `docs/operacao.md`. Mudou variável de ambiente, comando de operação ou volume: atualize esse guia. @@ -72,6 +85,7 @@ Banco: `mysql -u root -p < database/schema.sql` em instalação nova. Banco já - Commits no padrão `tipo(escopo): descrição` (`feat`, `fix`, `refactor`, `docs`, `test`, `chore`); branches `tipo/nome` com o mesmo tipo do commit (ex.: `feat/logout`, `fix/csrf-token`). Nunca commitar direto na `main`: tudo entra por PR, e o PR referencia a issue com `close #N` quando houver. - SQL sempre com prepared statements; o PDO usa `ATTR_EMULATE_PREPARES = false`, então o mesmo placeholder não pode aparecer duas vezes na query (use `:p1`, `:p2`). - Máscaras de telefone e CPF/CNPJ ficam em `src/ts/masks.ts` (`data-mask="phone"` / `"document"` no input), compiladas com `tsc` para `public/js/masks.js`, que é versionado: rode `tsc` ao mexer no `.ts`. É só apresentação; o back-end continua limpando os dígitos. +- `src/ts/forms.ts` (→ `public/js/forms.js`, em toda página com formulário) trava o clique duplo com "carregando" no botão e marca o campo apontado pelo erro: o `views/partials/flash.php` tem o mapa `$flashFields` (código de erro → `name` dos campos). Erro novo que é de um campo: adicione lá também. Nada de `style=""`, `', $this->lastBody, $route); + } + + $this->loginAs('loja@teste.test'); + $this->get('merchant/dashboard'); + $this->assertStringContainsString('', $this->lastBody); + } + + public function testErroApontaOCampoESucessoNao(): void { + $this->get('merchant/register&error=documento_invalido'); + $this->assertStringContainsString( + '