From f89fd7eaef245856014d273dcf907380fdafff2d Mon Sep 17 00:00:00 2001 From: "Emanuel G. Sousa" Date: Tue, 6 Oct 2026 16:28:36 -0300 Subject: [PATCH 01/15] refactor(lgpd): tira do customers as colunas de dado pessoal sem uso Migration 007 remove cpf, email, birth_date e gender (e os indices): o cliente e so o telefone. Task 49. Co-Authored-By: Claude Opus 5.5 --- .../migrations/007_customers_so_telefone.sql | 17 ++++++++++++ database/schema.sql | 13 +++------- docs/db/schema-explanation.md | 2 +- tests/Integration/CustomerModelTest.php | 26 +++++++++++++++++++ 4 files changed, 47 insertions(+), 11 deletions(-) create mode 100644 database/migrations/007_customers_so_telefone.sql diff --git a/database/migrations/007_customers_so_telefone.sql b/database/migrations/007_customers_so_telefone.sql new file mode 100644 index 0000000..f0d768a --- /dev/null +++ b/database/migrations/007_customers_so_telefone.sql @@ -0,0 +1,17 @@ +-- fidelityx - migration 007 (cliente e so o telefone, task 49) +-- para bancos criados antes desta versao do schema.sql. rodar uma unica vez: +-- php bin/migrate.php +-- (ou, a mao: mysql -u root -p fidelityx < database/migrations/007_customers_so_telefone.sql) + +USE fidelityx; + +-- cpf, email, birth_date e gender estavam "para o futuro" e nunca foram usados: o cliente e so o telefone +-- (task 11, docs/adr/002) e o nome fica no cartao de cada loja. coluna vazia de dado pessoal convida a +-- guardar dado sem base legal, entao sai. o indice unico do cpf e o do email saem junto. +ALTER TABLE customers + DROP KEY uq_customers_cpf, + DROP KEY idx_customers_email, + DROP COLUMN cpf, + DROP COLUMN email, + DROP COLUMN birth_date, + DROP COLUMN gender; diff --git a/database/schema.sql b/database/schema.sql index e2afb12..bfedab7 100644 --- a/database/schema.sql +++ b/database/schema.sql @@ -57,18 +57,10 @@ CREATE TABLE IF NOT EXISTS customers ( phone VARCHAR(30) NOT NULL, - -- Future-proofing (NULL) - cpf VARCHAR(11) NULL, - email VARCHAR(255) NULL, - birth_date DATE NULL, - gender ENUM('male', 'female', 'other') NULL, - created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, - UNIQUE KEY uq_customers_phone (phone), - UNIQUE KEY uq_customers_cpf (cpf), - KEY idx_customers_email (email) + UNIQUE KEY uq_customers_phone (phone) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; -- ========================= @@ -207,4 +199,5 @@ INSERT IGNORE INTO schema_migrations (version) VALUES ('003_lgpd'), ('004_estorno'), ('005_regra_pontos'), - ('006_points_log_extrato'); + ('006_points_log_extrato'), + ('007_customers_so_telefone'); diff --git a/docs/db/schema-explanation.md b/docs/db/schema-explanation.md index e6aa59c..c884aaf 100644 --- a/docs/db/schema-explanation.md +++ b/docs/db/schema-explanation.md @@ -36,7 +36,7 @@ merchants 1───N loyalty_cards N───1 customers |---|---| | `phone` | `UNIQUE`, 10 ou 11 dígitos sem máscara. É a chave de busca no balcão e na consulta pública. | -| `cpf`, `email`, `birth_date`, `gender` | Opcionais, para uso futuro. | +O cliente é **só o telefone** (task 11, ADR 002). As colunas `cpf`, `email`, `birth_date` e `gender`, que estavam “para uso futuro”, saíram na migration 007 (task 49): dado pessoal sem uso e sem base legal não fica no banco. Nome e consentimento ficam no cartão de cada loja. ### `loyalty_cards` — cartões de fidelidade | Coluna | Observação | diff --git a/tests/Integration/CustomerModelTest.php b/tests/Integration/CustomerModelTest.php index a660d06..fb5cc7e 100644 --- a/tests/Integration/CustomerModelTest.php +++ b/tests/Integration/CustomerModelTest.php @@ -7,6 +7,32 @@ // cliente = so o telefone (o nome fica no cartao de cada loja, ver LgpdTest) final class CustomerModelTest extends DatabaseTestCase { + private function columns(): array { + return $this->db->query('SHOW COLUMNS FROM customers')->fetchAll(\PDO::FETCH_COLUMN); + } + + // task 49: nada de cpf, e-mail, nascimento ou genero guardados "para o futuro" + public function testTabelaGuardaSoOTelefone(): void { + $this->assertSame(['id', 'phone', 'created_at', 'updated_at'], $this->columns()); + } + + // a migration 007 leva um banco antigo (com as colunas) ao mesmo formato do schema.sql + public function testMigration007TiraAsColunasDeBancoAntigo(): void { + $this->db->exec("ALTER TABLE customers + ADD cpf VARCHAR(11) NULL, ADD email VARCHAR(255) NULL, ADD birth_date DATE NULL, + ADD gender ENUM('male', 'female', 'other') NULL, + ADD UNIQUE KEY uq_customers_cpf (cpf), ADD KEY idx_customers_email (email)"); + + $file = dirname(__DIR__, 2) . '/database/migrations/007_customers_so_telefone.sql'; + foreach (\App\Support\Migrator::statements((string)file_get_contents($file)) as $statement) { + $this->db->exec($statement); + } + + $this->assertSame(['id', 'phone', 'created_at', 'updated_at'], $this->columns()); + $keys = $this->db->query('SHOW INDEX FROM customers')->fetchAll(\PDO::FETCH_COLUMN, 2); + $this->assertSame(['PRIMARY', 'uq_customers_phone'], array_values(array_unique($keys))); + } + public function testFindOrCreateCriaClienteNovo(): void { $id = (new CustomerModel($this->db))->findOrCreate('11999990001'); From 72fbd4941d625301fbcf563c18604672fcc1275c Mon Sep 17 00:00:00 2001 From: "Emanuel G. Sousa" Date: Tue, 6 Oct 2026 16:33:55 -0300 Subject: [PATCH 02/15] feat(cadastro): aceita cnpj alfanumerico Digito verificador pela regra nova da Receita (codigo ascii - 48), cnpj numerico continua valendo, mascara com letras, perfil formatado e ADR 001. Task 57. Co-Authored-By: Claude Opus 5.5 --- CLAUDE.md | 2 +- docs/adr/001-documents-validation.md | 11 ++- public/js/masks.js | 19 ++--- src/Controllers/MerchantController.php | 3 +- src/Support/helpers.php | 13 +-- src/Validators/DocumentValidator.php | 107 ++++++++++--------------- src/ts/masks.ts | 19 ++--- tests/Feature/RegisterTest.php | 25 ++++++ tests/Unit/DocumentValidatorTest.php | 15 ++++ tests/Unit/HelpersTest.php | 3 + tests/js/masks.test.js | 2 + views/auth/merchant/register.php | 2 +- 12 files changed, 127 insertions(+), 94 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 362032d..4a27141 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -57,7 +57,7 @@ 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). diff --git a/docs/adr/001-documents-validation.md b/docs/adr/001-documents-validation.md index f1ae765..aa35746 100644 --- a/docs/adr/001-documents-validation.md +++ b/docs/adr/001-documents-validation.md @@ -13,10 +13,19 @@ Optou-se por não integrar APIs externas neste momento. - [x] Sanitizar o input (remoção de máscara e caracteres inválidos) - [x] Validar CPF/CNPJ usando dígitos verificadores - [x] Rejeitar sequências inválidas (ex: 11111111111) -- [x] Armazenar apenas o valor normalizado (somente números) +- [x] Armazenar apenas o valor normalizado (somente números; no CNPJ alfanumérico, números e letras maiúsculas) - [x] Responsabilizar o usuário pela veracidade dos dados informados - [x] Verificação de duplicidade no banco (UNIQUE) +## Atualização: CNPJ alfanumérico (task 57) + +A partir de julho de 2026 a Receita Federal emite CNPJ com letras: as 12 primeiras posições (raiz + ordem) aceitam `0-9` e `A-Z`, e os 2 dígitos verificadores continuam numéricos. Os CNPJs só com números já emitidos continuam valendo, e o cálculo antigo é um caso particular do novo. + +- **Cálculo**: cada caractere vale o seu código ASCII menos 48 (`0`–`9` = 0–9, `A` = 17 … `Z` = 42), com os mesmos pesos e o mesmo módulo 11 de antes. Exemplo oficial: `12.ABC.345/01DE-35`. +- **Normalização** (`DocumentValidator::normalize`): maiúsculas, só `0-9A-Z`. CPF continua só com números (11 dígitos); CNPJ = 12 alfanuméricos + 2 dígitos. +- **Banco**: a coluna `merchants.cnpj VARCHAR(14)` já comporta o formato, sem migration. O valor é gravado em maiúsculas; a collation `utf8mb4_unicode_ci` também trata `abc` e `ABC` como iguais no `UNIQUE`. +- **Tela**: a máscara (`src/ts/masks.ts`) aceita letras e as põe em maiúsculas; o campo do cadastro deixou de usar `inputmode="numeric"` (o teclado numérico do celular não tem letras). `format_document` usa a mesma máscara para os dois formatos. + ## Possíveis melhorias futuras - [ ] Integração com API externa para validação real (Receita/terceiros) diff --git a/public/js/masks.js b/public/js/masks.js index 295fa67..e8046a0 100644 --- a/public/js/masks.js +++ b/public/js/masks.js @@ -21,20 +21,21 @@ function maskPhone(value) { const split = d.length === 11 ? 7 : 6; return ddd + d.slice(2, split) + "-" + d.slice(split); } -// cpf (ate 11 digitos) "000.000.000-00"; passou de 11 vira cnpj "00.000.000/0000-00" +// cpf (ate 11 digitos) "000.000.000-00"; passou de 11 (ou tem letra) vira cnpj "00.000.000/0000-00". +// o cnpj alfanumerico (task 57) tem letras nas 12 primeiras posicoes: entram em maiuscula, com a mesma mascara. function maskDocument(value) { - const d = onlyDigits(value).slice(0, 14); - if (d.length <= 11) { - return d + const c = value.toUpperCase().replace(/[^0-9A-Z]/g, "").slice(0, 14); + if (c.length <= 11 && /^\d*$/.test(c)) { + return c .replace(/^(\d{3})(\d)/, "$1.$2") .replace(/^(\d{3})\.(\d{3})(\d)/, "$1.$2.$3") .replace(/^(\d{3})\.(\d{3})\.(\d{3})(\d)/, "$1.$2.$3-$4"); } - return d - .replace(/^(\d{2})(\d)/, "$1.$2") - .replace(/^(\d{2})\.(\d{3})(\d)/, "$1.$2.$3") - .replace(/^(\d{2})\.(\d{3})\.(\d{3})(\d)/, "$1.$2.$3/$4") - .replace(/^(\d{2})\.(\d{3})\.(\d{3})\/(\d{4})(\d)/, "$1.$2.$3/$4-$5"); + return c + .replace(/^(\w{2})(\w)/, "$1.$2") + .replace(/^(\w{2})\.(\w{3})(\w)/, "$1.$2.$3") + .replace(/^(\w{2})\.(\w{3})\.(\w{3})(\w)/, "$1.$2.$3/$4") + .replace(/^(\w{2})\.(\w{3})\.(\w{3})\/(\w{4})(\w)/, "$1.$2.$3/$4-$5"); } const MASKS = { phone: maskPhone, diff --git a/src/Controllers/MerchantController.php b/src/Controllers/MerchantController.php index 17fc642..6cd56f5 100644 --- a/src/Controllers/MerchantController.php +++ b/src/Controllers/MerchantController.php @@ -446,7 +446,8 @@ public function handleRegister(){ $passwordConfirm = $_POST['password_confirm'] ?? ''; // tratamento dos input masks vindos do front-end. - $document = preg_replace('/\D/', '', (string)$document); + // cpf so numeros; cnpj pode ter letras (alfanumerico, task 57): fica so letra maiuscula e digito + $document = DocumentValidator::normalize($document); $phone = PhoneValidator::sanitize($phone); // verificacao dos dados: primeiro campos vazios/fora da lista, depois as regras especificas diff --git a/src/Support/helpers.php b/src/Support/helpers.php index e8ed919..702248d 100644 --- a/src/Support/helpers.php +++ b/src/Support/helpers.php @@ -28,15 +28,16 @@ function format_phone($phone): string { } // "52998224725" -> "529.982.247-25"; "11222333000181" -> "11.222.333/0001-81" (so pra exibicao) +// cnpj alfanumerico (task 57) tem letras nas 12 primeiras posicoes: a mascara e a mesma function format_document($document): string { - $digits = preg_replace('/\D/', '', (string)$document); - if (strlen($digits) === 11) { - return sprintf('%s.%s.%s-%s', substr($digits, 0, 3), substr($digits, 3, 3), substr($digits, 6, 3), substr($digits, 9)); + $chars = preg_replace('/[^0-9A-Z]/', '', strtoupper((string)$document)); + if (strlen($chars) === 11) { + return sprintf('%s.%s.%s-%s', substr($chars, 0, 3), substr($chars, 3, 3), substr($chars, 6, 3), substr($chars, 9)); } - if (strlen($digits) === 14) { - return sprintf('%s.%s.%s/%s-%s', substr($digits, 0, 2), substr($digits, 2, 3), substr($digits, 5, 3), substr($digits, 8, 4), substr($digits, 12)); + if (strlen($chars) === 14) { + return sprintf('%s.%s.%s/%s-%s', substr($chars, 0, 2), substr($chars, 2, 3), substr($chars, 5, 3), substr($chars, 8, 4), substr($chars, 12)); } - return $digits; + return $chars; } // fuso da aplicacao: APP_TIMEZONE do .env (nome IANA) ou horario de Brasilia. diff --git a/src/Validators/DocumentValidator.php b/src/Validators/DocumentValidator.php index 7047e86..b59ce9b 100644 --- a/src/Validators/DocumentValidator.php +++ b/src/Validators/DocumentValidator.php @@ -2,15 +2,25 @@ namespace App\Validators; +// cpf ou cnpj, so pelo digito verificador (sem consulta externa, ver docs/adr/001). +// desde julho de 2026 a Receita emite cnpj alfanumerico: as 12 primeiras posicoes podem ter letras +// (A-Z) e os 2 digitos verificadores continuam numericos. os cnpjs so com numeros continuam valendo. class DocumentValidator{ + // o que o lojista digitou -> so letras maiusculas e digitos (tira mascara, espaco e pontuacao). + // e esse o valor gravado no banco. + public static function normalize($document): string { + return preg_replace('/[^0-9A-Z]/', '', strtoupper((string)$document)); + } + public static function isValid($document){ - - $document = preg_replace('/\D/', '', (string)$document); - if(strlen($document) == 11){ + $document = self::normalize($document); + + // cpf e so numero; cnpj tem 12 posicoes alfanumericas + 2 digitos verificadores + if(preg_match('/^\d{11}$/', $document)){ return self::validarCPF($document); } - elseif(strlen($document) == 14){ + elseif(preg_match('/^[0-9A-Z]{12}\d{2}$/', $document)){ return self::validarCNPJ($document); } @@ -18,100 +28,65 @@ public static function isValid($document){ } private static function validarCPF($document){ - + // bloqueia cpf invalido com todos os dígitos iguais // Ex: 11111111111, 00000000000, etc if (preg_match('/(\d)\1{10}/', $document)) { return false; } - + // ============================ // CÁLCULO DOS DÍGITOS // ============================ - + // Loop para calcular os 2 últimos dígitos for ($t = 9; $t < 11; $t++) { - + $soma = 0; - + // Multiplica cada dígito pelos pesos decrescentes for ($i = 0; $i < $t; $i++) { $soma += $document[$i] * (($t + 1) - $i); } - + // Aplica regra do módulo 11 $digito = ((10 * $soma) % 11) % 10; - + // Compara com o dígito real do CPF if ($document[$t] != $digito) { return false; } } - + // se passou por tudo é valido return true; } + // regra da Receita (vale para o numerico e o alfanumerico): cada caractere vale o codigo ascii + // menos 48 ('0'..'9' = 0..9, 'A' = 17 ... 'Z' = 42), pesos 5 4 3 2 9 8 7 6 5 4 3 2 no primeiro + // digito e 6 5 4 3 2 9 8 7 6 5 4 3 2 no segundo, modulo 11 (resto 0 ou 1 = digito 0). private static function validarCNPJ($document){ // bloqueia sequências inválidas (ex: 11111111111111) - if (preg_match('/(\d)\1{13}/', $document)) { + if (preg_match('/^(.)\1{13}$/', $document)) { return false; } - // PRIMEIRO DÍGITO VERIFICADOR - - $tamanho = 12; // base sem os 2 dígitos finais - $numeros = substr($document, 0, $tamanho); - - $soma = 0; - $peso = 5; - - // multiplica com pesos: 5 4 3 2 9 8 7 6 5 4 3 2 - for ($i = 0; $i < $tamanho; $i++) { - $soma += $numeros[$i] * $peso; - $peso--; - - // quando chega em 2, reinicia em 9 - if ($peso < 2) { - $peso = 9; - } - } - - // regra modulo 11 - $resto = $soma % 11; - $digito1 = ($resto < 2) ? 0 : 11 - $resto; - - // verifica o primeiro digito - if ($document[12] != $digito1) { - return false; - } - - // segundo digito - - $tamanho = 13; - $numeros = substr($document, 0, $tamanho); - + $digito1 = self::digitoCNPJ(substr($document, 0, 12)); + $digito2 = self::digitoCNPJ(substr($document, 0, 12) . $digito1); + + return $document[12] === (string)$digito1 && $document[13] === (string)$digito2; + } + + private static function digitoCNPJ(string $base): int { $soma = 0; - $peso = 6; - - // pesos: 6 5 4 3 2 9 8 7 6 5 4 3 2 - for ($i = 0; $i < $tamanho; $i++) { - $soma += $numeros[$i] * $peso; - $peso--; - - if ($peso < 2) { - $peso = 9; - } + // primeiro digito (12 posicoes) comeca no peso 5; o segundo (13) no 6. quando chega em 2, volta pro 9 + $peso = strlen($base) - 7; + for ($i = 0; $i < strlen($base); $i++) { + $soma += (ord($base[$i]) - 48) * $peso; + $peso = $peso === 2 ? 9 : $peso - 1; } - + $resto = $soma % 11; - $digito2 = ($resto < 2) ? 0 : 11 - $resto; - - // verificar segundo digito - if ($document[13] != $digito2) { - return false; - } - - return true; + return $resto < 2 ? 0 : 11 - $resto; } -} \ No newline at end of file +} diff --git a/src/ts/masks.ts b/src/ts/masks.ts index e1fe1e4..a79ff2d 100644 --- a/src/ts/masks.ts +++ b/src/ts/masks.ts @@ -20,20 +20,21 @@ function maskPhone(value: string): string { return ddd + d.slice(2, split) + "-" + d.slice(split); } -// cpf (ate 11 digitos) "000.000.000-00"; passou de 11 vira cnpj "00.000.000/0000-00" +// cpf (ate 11 digitos) "000.000.000-00"; passou de 11 (ou tem letra) vira cnpj "00.000.000/0000-00". +// o cnpj alfanumerico (task 57) tem letras nas 12 primeiras posicoes: entram em maiuscula, com a mesma mascara. function maskDocument(value: string): string { - const d = onlyDigits(value).slice(0, 14); - if (d.length <= 11) { - return d + const c = value.toUpperCase().replace(/[^0-9A-Z]/g, "").slice(0, 14); + if (c.length <= 11 && /^\d*$/.test(c)) { + return c .replace(/^(\d{3})(\d)/, "$1.$2") .replace(/^(\d{3})\.(\d{3})(\d)/, "$1.$2.$3") .replace(/^(\d{3})\.(\d{3})\.(\d{3})(\d)/, "$1.$2.$3-$4"); } - return d - .replace(/^(\d{2})(\d)/, "$1.$2") - .replace(/^(\d{2})\.(\d{3})(\d)/, "$1.$2.$3") - .replace(/^(\d{2})\.(\d{3})\.(\d{3})(\d)/, "$1.$2.$3/$4") - .replace(/^(\d{2})\.(\d{3})\.(\d{3})\/(\d{4})(\d)/, "$1.$2.$3/$4-$5"); + return c + .replace(/^(\w{2})(\w)/, "$1.$2") + .replace(/^(\w{2})\.(\w{3})(\w)/, "$1.$2.$3") + .replace(/^(\w{2})\.(\w{3})\.(\w{3})(\w)/, "$1.$2.$3/$4") + .replace(/^(\w{2})\.(\w{3})\.(\w{3})\/(\w{4})(\w)/, "$1.$2.$3/$4-$5"); } const MASKS: { [name: string]: (value: string) => string } = { diff --git a/tests/Feature/RegisterTest.php b/tests/Feature/RegisterTest.php index 3ab36ae..207fc68 100644 --- a/tests/Feature/RegisterTest.php +++ b/tests/Feature/RegisterTest.php @@ -27,6 +27,31 @@ public function testSenhaPrecisaTerDe8A72Bytes(): void { $this->loginAs('padaria@teste.test', '12345678'); } + // task 57: cnpj alfanumerico (desde jul/2026) cadastra, grava em maiusculas e aparece formatado no perfil + public function testCadastraCnpjAlfanumerico(): void { + $this->assertSame('merchant/login&success=cadastrado', $this->register(['document' => '12.abc.345/01de-35'])); + + $this->assertSame('12ABC34501DE35', $this->scalar('SELECT cnpj FROM merchants')); + $this->assertNull($this->scalar('SELECT cpf FROM merchants')); + + $this->loginAs('padaria@teste.test'); + $this->get('merchant/profile'); + $this->assertStringContainsString('value="12.ABC.345/01DE-35"', $this->lastBody); + $this->assertStringContainsString('>CNPJ<', $this->lastBody); + } + + public function testCnpjNumericoContinuaValendoEAlfanumericoErradoNao(): void { + $this->assertSame('merchant/register&error=documento_invalido', $this->register(['document' => '12.ABC.345/01DE-36'])); + $this->assertSame('merchant/login&success=cadastrado', $this->register(['document' => '11.222.333/0001-81'])); + $this->assertSame('11222333000181', $this->scalar('SELECT cnpj FROM merchants')); + } + + // o mesmo cnpj digitado em minusculas nao vira uma segunda conta + public function testCnpjAlfanumericoRepetidoEmMinusculasEDuplicado(): void { + $this->register(['document' => '12ABC34501DE35']); + $this->assertSame('merchant/register&error=ja_cadastrado', $this->register(['document' => '12abc34501de35', 'email' => 'outra@teste.test'])); + } + // senha antiga, de antes da regra, continua entrando public function testSenhaCurtaCadastradaAntesDaRegraContinuaValendo(): void { $id = $this->createMerchant('loja@teste.test'); diff --git a/tests/Unit/DocumentValidatorTest.php b/tests/Unit/DocumentValidatorTest.php index b360ee3..15f9613 100644 --- a/tests/Unit/DocumentValidatorTest.php +++ b/tests/Unit/DocumentValidatorTest.php @@ -15,6 +15,10 @@ public static function validos(): array { 'cnpj sem mascara' => ['11222333000181'], 'cnpj com mascara' => ['11.222.333/0001-81'], 'outro cnpj' => ['11.444.777/0001-61'], + // task 57: exemplo oficial da Receita de cnpj alfanumerico + 'cnpj alfanumerico' => ['12ABC34501DE35'], + 'cnpj alfanumerico com mascara' => ['12.ABC.345/01DE-35'], + 'cnpj alfanumerico minusculo' => ['12.abc.345/01de-35'], ]; } @@ -31,9 +35,20 @@ public static function invalidos(): array { 'longo demais' => ['112223330001811'], 'vazio' => [''], 'so letras' => ['abc.def.ghi-jk'], + 'cnpj alfanumerico com dv errado' => ['12ABC34501DE36'], + 'letra no digito verificador' => ['12ABC34501DE3A'], + 'cpf com letra' => ['5299822472A'], + 'cnpj todas letras iguais' => ['AAAAAAAAAAAAAA'], + 'letra fora de A-Z' => ['12ÁBC34501DE35'], ]; } + public function testNormalizaParaMaiusculasEDigitos(): void { + $this->assertSame('12ABC34501DE35', DocumentValidator::normalize(' 12.abc.345/01de-35 ')); + $this->assertSame('52998224725', DocumentValidator::normalize('529.982.247-25')); + $this->assertSame('', DocumentValidator::normalize(null)); + } + #[DataProvider('validos')] public function testAceitaDocumentoValido(string $document): void { $this->assertTrue(DocumentValidator::isValid($document)); diff --git a/tests/Unit/HelpersTest.php b/tests/Unit/HelpersTest.php index 2ac8ca0..234e2e1 100644 --- a/tests/Unit/HelpersTest.php +++ b/tests/Unit/HelpersTest.php @@ -9,6 +9,9 @@ public function testFormatDocument(): void { $this->assertSame('529.982.247-25', format_document('52998224725')); $this->assertSame('11.222.333/0001-81', format_document('11222333000181')); $this->assertSame('529.982.247-25', format_document('529.982.247-25'), 'ja formatado continua igual'); + // task 57: cnpj alfanumerico, com a mesma mascara + $this->assertSame('12.ABC.345/01DE-35', format_document('12ABC34501DE35')); + $this->assertSame('12.ABC.345/01DE-35', format_document('12.abc.345/01de-35')); $this->assertSame('123', format_document('123'), 'tamanho desconhecido sai so com os digitos'); $this->assertSame('', format_document(null)); } diff --git a/tests/js/masks.test.js b/tests/js/masks.test.js index d207412..6ae7fa6 100644 --- a/tests/js/masks.test.js +++ b/tests/js/masks.test.js @@ -11,6 +11,8 @@ const cases = [ ['d', '529', '529'], ['d', '5299', '529.9'], ['d', '52998224725', '529.982.247-25'], ['d', '529.982.247-25', '529.982.247-25'], ['d', '112223330001', '11.222.333/0001'], ['d', '11222333000181', '11.222.333/0001-81'], ['d', '112223330001819999', '11.222.333/0001-81'], + ['d', '12ABC34501DE35', '12.ABC.345/01DE-35'], ['d', '12.abc.345/01de-35', '12.ABC.345/01DE-35'], + ['d', '12A', '12.A'], ['d', '12ABC', '12.ABC'], ['d', 'ab', 'AB'], ['d', '12.ÁBC', '12.BC'], ]; for (const [f, i, o] of cases) assert.strictEqual(ctx[f](i), o, f + '(' + i + ')'); console.log('ok', cases.length); diff --git a/views/auth/merchant/register.php b/views/auth/merchant/register.php index 91eded6..067a0ca 100644 --- a/views/auth/merchant/register.php +++ b/views/auth/merchant/register.php @@ -46,7 +46,7 @@
- +
From 13fff430c47b760f450277ace9bd1ebd1dc712c0 Mon Sep 17 00:00:00 2001 From: "Emanuel G. Sousa" Date: Tue, 6 Oct 2026 16:40:17 -0300 Subject: [PATCH 03/15] feat(balcao): estorno de resgate pelo extrato Os pontos voltam ao saldo com um reversal no points_log, o total acumulado nao muda e os relatorios deixam de contar o resgate desfeito. Task 44. Co-Authored-By: Claude Opus 5.5 --- CLAUDE.md | 2 +- database/schema.sql | 4 +- docs/db/schema-explanation.md | 4 +- src/Controllers/MerchantController.php | 12 ++--- src/Models/LoyaltyCardModel.php | 43 +++++++++++------- src/Models/PointsLogModel.php | 32 +++++++++----- src/Support/helpers.php | 16 ++++--- tests/Feature/ReversalScreenTest.php | 33 ++++++++++++++ tests/Integration/ReversalTest.php | 61 +++++++++++++++++++++++--- views/merchant/reports.php | 2 +- views/merchant/statement.php | 7 ++- views/partials/flash.php | 5 ++- 12 files changed, 166 insertions(+), 55 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 4a27141..3bfeff4 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -44,7 +44,7 @@ Banco: `mysql -u root -p < database/schema.sql` em instalação nova. Banco já **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. -**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. Listas longas (clientes, extrato, relatórios) paginam com `App\Support\Paginator` + `views/partials/pagination.php`, 20 por página. **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. diff --git a/database/schema.sql b/database/schema.sql index bfedab7..70c3e0f 100644 --- a/database/schema.sql +++ b/database/schema.sql @@ -132,11 +132,11 @@ CREATE TABLE IF NOT EXISTS points_log ( id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY, card_id BIGINT UNSIGNED NOT NULL, - type ENUM('earn', 'redeem', 'reversal') NOT NULL, -- reversal = estorno de um earn + type ENUM('earn', 'redeem', 'reversal') NOT NULL, -- reversal = estorno de um earn ou de um redeem quantity INT NOT NULL, description VARCHAR(255) NOT NULL, reward_id BIGINT UNSIGNED NULL, -- preenchido quando type = 'redeem' - reverses_id BIGINT UNSIGNED NULL, -- preenchido quando type = 'reversal': o lancamento estornado + reverses_id BIGINT UNSIGNED NULL, -- preenchido quando type = 'reversal': o ganho ou resgate estornado -- para futuras implementações (NULL) responsible_user VARCHAR(255) NULL, diff --git a/docs/db/schema-explanation.md b/docs/db/schema-explanation.md index c884aaf..9927d7e 100644 --- a/docs/db/schema-explanation.md +++ b/docs/db/schema-explanation.md @@ -59,9 +59,9 @@ O cliente é **só o telefone** (task 11, ADR 002). As colunas `cpf`, `email`, ` ### `points_log` — histórico | Coluna | Observação | |---|---| -| `type` | `earn` (ganho), `redeem` (resgate) ou `reversal` (estorno de um ganho). `quantity` é sempre positivo. | +| `type` | `earn` (ganho), `redeem` (resgate) ou `reversal` (estorno de um ganho ou de um resgate, task 44). `quantity` é sempre positivo: o sinal vem do tipo (o estorno de resgate devolve pontos). | | `reward_id` | Preenchido em resgates. Vira `NULL` se o prêmio for apagado. | -| `reverses_id` | Preenchido em estornos (`reversal`): o lançamento estornado. `UNIQUE`, então cada lançamento é estornado no máximo uma vez. | +| `reverses_id` | Preenchido em estornos (`reversal`): o ganho ou resgate estornado. `UNIQUE`, então cada movimentação é estornada no máximo uma vez. | | `ip_address` | IP de quem fez a operação (o do cliente, mesmo atrás de proxy confiável: `TRUSTED_PROXIES`). | | índice `idx_points_log_card_created` | `(card_id, created_at)`: o extrato de um cartão é lido já na ordem de data, sem ordenar tudo a cada página. Também atende a chave estrangeira de `card_id`. | diff --git a/src/Controllers/MerchantController.php b/src/Controllers/MerchantController.php index 6cd56f5..ede1054 100644 --- a/src/Controllers/MerchantController.php +++ b/src/Controllers/MerchantController.php @@ -634,17 +634,17 @@ private function handleCustomer($merchantId) { redirect('merchant/dashboard', ['success' => 'cliente_excluido']); } - // estorno de lancamento (do extrato ou do Desfazer da confirmacao). volta pra tela de onde veio. + // estorno de lancamento (do extrato ou do Desfazer da confirmacao) ou de resgate (do extrato, task 44). + // volta pra tela de onde veio. if (($_POST['action'] ?? '') === 'reverse') { $back = ($_POST['back'] ?? '') === 'statement' ? 'merchant/statement' : 'merchant/customer'; $logId = filter_var($_POST['log_id'] ?? '', FILTER_VALIDATE_INT); - // o lancamento precisa ser do cartao deste cliente (o model confere tambem a loja) - $result = $logId && (new PointsLogModel($this->db))->belongsToCard($logId, (int)$card['id']) - ? $cardModel->reverse($logId, $merchantId) - : 'not_found'; + // a movimentacao precisa ser do cartao deste cliente (o model confere tambem a loja) + $type = $logId ? (new PointsLogModel($this->db))->typeForCard($logId, (int)$card['id']) : null; + $result = $type !== null ? $cardModel->reverse($logId, $merchantId) : 'not_found'; redirect($back, ['phone' => $phone] + match ($result) { - 'ok' => ['success' => 'lancamento_estornado'], + 'ok' => ['success' => $type === 'redeem' ? 'resgate_estornado' : 'lancamento_estornado'], 'already' => ['error' => 'estorno_repetido'], 'expired' => ['error' => 'estorno_expirado'], 'insufficient' => ['error' => 'estorno_sem_saldo'], diff --git a/src/Models/LoyaltyCardModel.php b/src/Models/LoyaltyCardModel.php index b57f331..cafb9e3 100644 --- a/src/Models/LoyaltyCardModel.php +++ b/src/Models/LoyaltyCardModel.php @@ -70,10 +70,12 @@ public function addPoints($cardId, $points, $description): int { } } - // estorno de um lancamento de pontos (digitou 500 em vez de 50). nada e apagado: o estorno vira uma - // linha nova 'reversal' que aponta pro lancamento, e o saldo e o total acumulado voltam ao que eram. - // so vale para lancamento (earn) desta loja, feito nas ultimas REVERSAL_WINDOW_HOURS horas, ainda nao estornado, - // e quando o saldo atual cobre os pontos (se o cliente ja gastou, o saldo ficaria negativo). + // estorno de um lancamento de pontos (digitou 500 em vez de 50) ou de um resgate feito por engano (task 44). + // nada e apagado: o estorno vira uma linha nova 'reversal' que aponta pra movimentacao estornada. + // - lancamento (earn): saldo e total acumulado voltam ao que eram, e so quando o saldo atual cobre os pontos + // (se o cliente ja gastou, o saldo ficaria negativo). + // - resgate (redeem): os pontos voltam ao saldo; o total acumulado nao muda (o resgate nao mexeu nele). + // so vale para movimentacao desta loja, feita nas ultimas REVERSAL_WINDOW_HOURS horas e ainda nao estornada. // devolve 'ok', 'not_found', 'already', 'expired' ou 'insufficient'. public const REVERSAL_WINDOW_HOURS = 24; @@ -81,12 +83,13 @@ public function reverse($logId, $merchantId): string { $this->db->beginTransaction(); try { $stmt = $this->db->prepare( - "SELECT pl.id, pl.card_id, pl.quantity, pl.description, + "SELECT pl.id, pl.card_id, pl.type, pl.quantity, pl.description, pl.created_at >= NOW() - INTERVAL " . self::REVERSAL_WINDOW_HOURS . " HOUR AS recent, EXISTS (SELECT 1 FROM points_log r WHERE r.reverses_id = pl.id) AS reversed FROM points_log pl JOIN loyalty_cards lc ON lc.id = pl.card_id - WHERE pl.id = :id AND lc.merchant_id = :merchant_id AND pl.type = 'earn' AND lc.anonymized_at IS NULL" + WHERE pl.id = :id AND lc.merchant_id = :merchant_id AND pl.type IN ('earn', 'redeem') + AND lc.anonymized_at IS NULL" ); $stmt->execute([':id' => $logId, ':merchant_id' => $merchantId]); $entry = $stmt->fetch(\PDO::FETCH_ASSOC); @@ -106,17 +109,25 @@ public function reverse($logId, $merchantId): string { $stmt = $this->db->prepare('SELECT current_points FROM loyalty_cards WHERE id = :id FOR UPDATE'); $stmt->execute([':id' => $entry['card_id']]); $quantity = (int)$entry['quantity']; - if ((int)$stmt->fetchColumn() < $quantity) { - $this->db->rollBack(); - return 'insufficient'; - } - $this->db->prepare( - 'UPDATE loyalty_cards - SET current_points = current_points - :p1, total_accumulated = total_accumulated - :p2, - last_use_at = CURRENT_TIMESTAMP - WHERE id = :id' - )->execute([':p1' => $quantity, ':p2' => $quantity, ':id' => $entry['card_id']]); + if ($entry['type'] === 'redeem') { + $this->db->prepare( + 'UPDATE loyalty_cards SET current_points = current_points + :p1, last_use_at = CURRENT_TIMESTAMP + WHERE id = :id' + )->execute([':p1' => $quantity, ':id' => $entry['card_id']]); + } else { + if ((int)$stmt->fetchColumn() < $quantity) { + $this->db->rollBack(); + return 'insufficient'; + } + + $this->db->prepare( + 'UPDATE loyalty_cards + SET current_points = current_points - :p1, total_accumulated = total_accumulated - :p2, + last_use_at = CURRENT_TIMESTAMP + WHERE id = :id' + )->execute([':p1' => $quantity, ':p2' => $quantity, ':id' => $entry['card_id']]); + } $description = mb_substr('Estorno: ' . $entry['description'], 0, 255); $this->log($entry['card_id'], 'reversal', $quantity, $description, null, (int)$entry['id']); diff --git a/src/Models/PointsLogModel.php b/src/Models/PointsLogModel.php index 95dd7f1..8efd8c1 100644 --- a/src/Models/PointsLogModel.php +++ b/src/Models/PointsLogModel.php @@ -17,10 +17,12 @@ public function recentByMerchant($merchantId, $limit = 10) { // uma pagina do historico de movimentacoes da loja, da mais recente pra mais antiga. // LEFT JOIN: cartao anonimizado nao tem cliente, mas a movimentacao continua no historico (nome e telefone NULL) public function pageByMerchant($merchantId, int $limit, int $offset) { - $sql = 'SELECT pl.type, pl.quantity, pl.description, pl.created_at, lc.customer_name, c.phone + $sql = 'SELECT pl.type, pl.quantity, pl.description, pl.created_at, lc.customer_name, c.phone, + orig.type AS reversed_type FROM points_log pl JOIN loyalty_cards lc ON lc.id = pl.card_id LEFT JOIN customers c ON c.id = lc.customer_id + LEFT JOIN points_log orig ON orig.id = pl.reverses_id WHERE lc.merchant_id = :merchant_id ORDER BY pl.created_at DESC, pl.id DESC LIMIT ' . (int)$limit . ' OFFSET ' . (int)$offset; @@ -32,9 +34,11 @@ public function pageByMerchant($merchantId, int $limit, int $offset) { // extrato de um cartao: movimentacoes do cliente nesta loja, da mais recente pra mais antiga public function pageByCard($cardId, int $limit, int $offset) { - // can_reverse: lancamento que ainda pode ser estornado (o model confere tudo de novo no POST) + // can_reverse: lancamento ou resgate que ainda pode ser estornado (o model confere tudo de novo no POST). + // reversed_type: num estorno, o tipo do que foi estornado (estorno de resgate devolve pontos) $sql = 'SELECT pl.id, pl.type, pl.quantity, pl.description, pl.created_at, - (pl.type = \'earn\' + (SELECT orig.type FROM points_log orig WHERE orig.id = pl.reverses_id) AS reversed_type, + (pl.type IN (\'earn\', \'redeem\') AND pl.created_at >= NOW() - INTERVAL ' . LoyaltyCardModel::REVERSAL_WINDOW_HOURS . ' HOUR AND NOT EXISTS (SELECT 1 FROM points_log r WHERE r.reverses_id = pl.id)) AS can_reverse FROM points_log pl @@ -59,11 +63,13 @@ public function findFreshEarn($logId, $cardId, int $maxAgeSeconds) { return $stmt->fetch(\PDO::FETCH_ASSOC); } - // a movimentacao e deste cartao (o estorno confere antes, pra nao estornar lancamento de outro cliente) - public function belongsToCard(int $logId, int $cardId): bool { - $stmt = $this->db->prepare('SELECT 1 FROM points_log WHERE id = :id AND card_id = :card_id'); + // tipo da movimentacao, se ela for deste cartao; null se nao for (o estorno confere antes, + // pra nao estornar movimentacao de outro cliente) + public function typeForCard(int $logId, int $cardId): ?string { + $stmt = $this->db->prepare('SELECT type FROM points_log WHERE id = :id AND card_id = :card_id'); $stmt->execute([':id' => $logId, ':card_id' => $cardId]); - return (bool)$stmt->fetchColumn(); + $type = $stmt->fetchColumn(); + return $type === false ? null : $type; } public function countByCard($cardId): int { @@ -83,16 +89,20 @@ public function countByMerchant($merchantId): int { return (int)$stmt->fetchColumn(); } - // numeros do dashboard + // numeros do dashboard. estorno de lancamento desconta dos pontos emitidos; resgate estornado + // (task 44) nao conta como resgate, e o estorno dele nao mexe nos pontos emitidos. public function statsByMerchant($merchantId) { $sql = "SELECT (SELECT COUNT(*) FROM loyalty_cards WHERE merchant_id = :m1 AND anonymized_at IS NULL) AS customers, - (SELECT COALESCE(SUM(CASE pl.type WHEN 'earn' THEN pl.quantity ELSE -pl.quantity END), 0) FROM points_log pl + (SELECT COALESCE(SUM(CASE WHEN pl.type = 'earn' THEN pl.quantity ELSE -pl.quantity END), 0) FROM points_log pl JOIN loyalty_cards lc ON lc.id = pl.card_id - WHERE lc.merchant_id = :m2 AND pl.type IN ('earn', 'reversal')) AS points_issued, + LEFT JOIN points_log orig ON orig.id = pl.reverses_id + WHERE lc.merchant_id = :m2 + AND (pl.type = 'earn' OR (pl.type = 'reversal' AND orig.type = 'earn'))) AS points_issued, (SELECT COUNT(*) FROM points_log pl JOIN loyalty_cards lc ON lc.id = pl.card_id - WHERE lc.merchant_id = :m3 AND pl.type = 'redeem') AS redemptions, + WHERE lc.merchant_id = :m3 AND pl.type = 'redeem' + AND NOT EXISTS (SELECT 1 FROM points_log r WHERE r.reverses_id = pl.id)) AS redemptions, (SELECT COALESCE(SUM(current_points), 0) FROM loyalty_cards WHERE merchant_id = :m4) AS points_balance"; $stmt = $this->db->prepare($sql); $stmt->execute([':m1' => $merchantId, ':m2' => $merchantId, ':m3' => $merchantId, ':m4' => $merchantId]); diff --git a/src/Support/helpers.php b/src/Support/helpers.php index 702248d..6443594 100644 --- a/src/Support/helpers.php +++ b/src/Support/helpers.php @@ -58,12 +58,14 @@ function url(string $route, array $params = []): string { return '/index.php?' . http_build_query(array_merge(['url' => $route], $params)); } -// como cada tipo de movimentacao aparece no extrato e nos relatorios: [rotulo, classe do selo, sinal] -function log_type_view(string $type): array { - return match ($type) { - 'earn' => ['Ganhou', 'badge-earn', '+'], - 'redeem' => ['Resgatou', 'badge-redeem', '−'], - 'reversal' => ['Estorno', 'badge-reversal', '−'], - default => [$type, '', ''], +// como cada tipo de movimentacao aparece no extrato e nos relatorios: [rotulo, classe do selo, sinal]. +// $reversedType: num estorno, o tipo do que foi estornado (estorno de resgate devolve os pontos: sinal +) +function log_type_view(string $type, ?string $reversedType = null): array { + return match (true) { + $type === 'earn' => ['Ganhou', 'badge-earn', '+'], + $type === 'redeem' => ['Resgatou', 'badge-redeem', '−'], + $type === 'reversal' && $reversedType === 'redeem' => ['Estorno de resgate', 'badge-reversal', '+'], + $type === 'reversal' => ['Estorno', 'badge-reversal', '−'], + default => [$type, '', ''], }; } diff --git a/tests/Feature/ReversalScreenTest.php b/tests/Feature/ReversalScreenTest.php index 5ef9855..494cf1f 100644 --- a/tests/Feature/ReversalScreenTest.php +++ b/tests/Feature/ReversalScreenTest.php @@ -34,6 +34,39 @@ public function testEstornaPeloExtrato(): void { $this->assertStringNotContainsString('name="log_id"', $this->lastBody); } + // task 44: resgate por engano se estorna pelo extrato e os pontos voltam + public function testEstornaResgatePeloExtrato(): void { + $merchant = $this->createMerchant('loja@teste.test'); + $card = $this->createCard($merchant, 'Bia', '11922220002'); + $reward = $this->createReward($merchant, 'Cafe', 20); + $cards = new LoyaltyCardModel($this->db); + $cards->addPoints($card, 30, 'Compra'); + $cards->redeem($card, $reward); + $resgate = (int)$this->scalar("SELECT id FROM points_log WHERE type = 'redeem'"); + $this->loginAs('loja@teste.test'); + + $this->get(self::STATEMENT); + $this->assertStringContainsString('name="log_id" value="' . $resgate . '"', $this->lastBody); + $this->assertStringContainsString('Estornar este resgate? Os pontos voltam ao saldo do cliente.', html_entity_decode($this->lastBody)); + + [, $location] = $this->post( + 'merchant/customer', + ['action' => 'reverse', 'back' => 'statement', 'phone' => '11922220002', 'log_id' => $resgate], + true, + self::STATEMENT + ); + $this->assertSame(self::STATEMENT . '&success=resgate_estornado', $location); + $this->assertSame(30, (int)$this->scalar('SELECT current_points FROM loyalty_cards')); + + $this->get(self::STATEMENT . '&success=resgate_estornado'); + $this->assertStringContainsString('Os pontos voltaram ao saldo', $this->lastBody); + $this->assertMatchesRegularExpression('#badge-reversal">Estorno de resgate\s*\+20<#', $this->lastBody); + + // relatorio: o resgate desfeito nao conta + $this->get('merchant/reports'); + $this->assertMatchesRegularExpression('#stat-value">0
\s*
Resgates#', $this->lastBody); + } + public function testErrosVoltamComMensagem(): void { $merchant = $this->createMerchant('loja@teste.test'); $card = $this->createCard($merchant, 'Bia', '11922220002'); diff --git a/tests/Integration/ReversalTest.php b/tests/Integration/ReversalTest.php index 71d0f91..cb2ca14 100644 --- a/tests/Integration/ReversalTest.php +++ b/tests/Integration/ReversalTest.php @@ -29,9 +29,9 @@ public function testMovimentacaoPertenceSoAoProprioCartao(): void { $other = $this->createCard($this->merchant, 'Caio', '11933330003'); $logs = new PointsLogModel($this->db); - $this->assertTrue($logs->belongsToCard($logId, $this->card)); - $this->assertFalse($logs->belongsToCard($logId, $other)); - $this->assertFalse($logs->belongsToCard($logId + 999, $this->card)); + $this->assertSame('earn', $logs->typeForCard($logId, $this->card)); + $this->assertNull($logs->typeForCard($logId, $other)); + $this->assertNull($logs->typeForCard($logId + 999, $this->card)); } public function testEstornoDevolveSaldoETotalENadaEApagado(): void { @@ -74,7 +74,7 @@ public function testSoLancamentoRecente(): void { $this->assertSame('expired', $this->cards->reverse($id, $this->merchant)); } - public function testSoLancamentoDeGanhoDestaLoja(): void { + public function testSoMovimentacaoDestaLojaENuncaOProprioEstorno(): void { $reward = $this->createReward($this->merchant, 'Cafe', 20); $ganho = $this->cards->addPoints($this->card, 30, 'Compra'); $this->cards->redeem($this->card, $reward); @@ -82,9 +82,60 @@ public function testSoLancamentoDeGanhoDestaLoja(): void { $outraLoja = $this->createMerchant('outra@teste.test'); - $this->assertSame('not_found', $this->cards->reverse($resgate, $this->merchant), 'resgate nao se estorna'); $this->assertSame('not_found', $this->cards->reverse($ganho, $outraLoja), 'outra loja nao estorna'); + $this->assertSame('not_found', $this->cards->reverse($resgate, $outraLoja), 'nem o resgate'); $this->assertSame('not_found', $this->cards->reverse(999999, $this->merchant)); + + $this->assertSame('ok', $this->cards->reverse($resgate, $this->merchant)); + $estorno = (int)$this->scalar("SELECT id FROM points_log WHERE type = 'reversal'"); + $this->assertSame('not_found', $this->cards->reverse($estorno, $this->merchant), 'estorno de estorno nao existe'); + } + + // task 44: resgate feito por engano volta pelo extrato + public function testEstornoDeResgateDevolveOsPontosSemMexerNoTotal(): void { + $reward = $this->createReward($this->merchant, 'Cafe', 20); + $this->cards->addPoints($this->card, 30, 'Compra'); + $this->cards->redeem($this->card, $reward); + $resgate = (int)$this->scalar("SELECT id FROM points_log WHERE type = 'redeem'"); + $this->assertSame(['current_points' => 10, 'total_accumulated' => 30], array_map('intval', $this->balance())); + + $this->assertSame('ok', $this->cards->reverse($resgate, $this->merchant)); + + $this->assertSame(['current_points' => 30, 'total_accumulated' => 30], array_map('intval', $this->balance())); + $row = $this->db->query("SELECT quantity, reverses_id, description FROM points_log WHERE type = 'reversal'")->fetch(); + $this->assertSame(20, (int)$row['quantity']); + $this->assertSame($resgate, (int)$row['reverses_id']); + $this->assertSame('Estorno: Resgate: Cafe', $row['description']); + $this->assertSame(3, (int)$this->scalar('SELECT COUNT(*) FROM points_log'), 'nada e apagado'); + + $this->assertSame('already', $this->cards->reverse($resgate, $this->merchant)); + $this->assertSame(30, (int)$this->balance()['current_points']); + } + + public function testResgateSoEstornaNas24Horas(): void { + $reward = $this->createReward($this->merchant, 'Cafe', 20); + $this->cards->addPoints($this->card, 30, 'Compra'); + $this->cards->redeem($this->card, $reward); + $this->db->exec("UPDATE points_log SET created_at = NOW() - INTERVAL " . (LoyaltyCardModel::REVERSAL_WINDOW_HOURS + 1) . " HOUR WHERE type = 'redeem'"); + $resgate = (int)$this->scalar("SELECT id FROM points_log WHERE type = 'redeem'"); + + $this->assertSame('expired', $this->cards->reverse($resgate, $this->merchant)); + $this->assertSame(10, (int)$this->balance()['current_points']); + } + + // resgate desfeito nao conta como resgate, e o estorno dele nao desconta dos pontos emitidos + public function testRelatorioContaResgateEstornadoComoDesfeito(): void { + $reward = $this->createReward($this->merchant, 'Cafe', 20); + $this->cards->addPoints($this->card, 50, 'Compra'); + $this->cards->redeem($this->card, $reward); + $this->cards->redeem($this->card, $reward); + $primeiro = (int)$this->scalar("SELECT MIN(id) FROM points_log WHERE type = 'redeem'"); + $this->cards->reverse($primeiro, $this->merchant); + + $stats = (new PointsLogModel($this->db))->statsByMerchant($this->merchant); + $this->assertSame(1, (int)$stats['redemptions']); + $this->assertSame(50, (int)$stats['points_issued']); + $this->assertSame(30, (int)$stats['points_balance']); } public function testPontosEmitidosDescontamOEstorno(): void { diff --git a/views/merchant/reports.php b/views/merchant/reports.php index 8c8ae2f..c76c12d 100644 --- a/views/merchant/reports.php +++ b/views/merchant/reports.php @@ -42,7 +42,7 @@ - + diff --git a/views/merchant/statement.php b/views/merchant/statement.php index 2b7ad4b..a6835ff 100644 --- a/views/merchant/statement.php +++ b/views/merchant/statement.php @@ -27,14 +27,17 @@ - + +
+ onsubmit="return confirm();"> diff --git a/views/partials/flash.php b/views/partials/flash.php index f5b4496..66ea366 100644 --- a/views/partials/flash.php +++ b/views/partials/flash.php @@ -32,8 +32,8 @@ 'valor_invalido' => 'Informe o valor da compra, ex.: 12,90.', 'valor_pontos_demais' => 'Essa compra daria mais de 10.000 pontos, o limite por lançamento. Confira o valor ou divida em mais de um lançamento.', 'valor_sem_pontos' => 'O valor da compra não chega a 1 ponto pela regra da loja.', - 'estorno_repetido' => 'Este lançamento já foi estornado.', - 'estorno_expirado' => 'Só dá para estornar lançamentos das últimas 24 horas.', + 'estorno_repetido' => 'Esta movimentação já foi estornada.', + 'estorno_expirado' => 'Só dá para estornar lançamentos e resgates das últimas 24 horas.', 'estorno_sem_saldo' => 'Não dá para estornar: o cliente já usou parte desses pontos.', 'senha_atual_incorreta' => 'A senha atual não confere.', 'muitas_tentativas' => 'Muitas tentativas com a senha atual errada. Aguarde alguns minutos e tente de novo.', @@ -52,6 +52,7 @@ 'consentimento_registrado' => 'Consentimento registrado.', 'cliente_excluido' => 'Dados do cliente excluídos.', 'lancamento_estornado' => 'Lançamento estornado. Os pontos saíram do saldo.', + 'resgate_estornado' => 'Resgate estornado. Os pontos voltaram ao saldo.', 'regra_salva' => 'Regra de pontos salva.', 'regra_removida' => 'Regra de pontos removida.', 'perfil_atualizado' => 'Dados da loja atualizados.', From c7ec9eee155daef276caf8d5c8616a4c59cc93ad Mon Sep 17 00:00:00 2001 From: "Emanuel G. Sousa" Date: Tue, 6 Oct 2026 16:45:09 -0300 Subject: [PATCH 04/15] feat(balcao): corrigir nome e trocar o telefone do cliente So o cartao desta loja muda (saldo, historico e consentimento vao junto); telefone que ja tem cartao nesta loja e recusado. Task 43. Co-Authored-By: Claude Opus 5.5 --- CLAUDE.md | 2 +- src/Controllers/MerchantController.php | 26 ++++++ src/Models/LoyaltyCardModel.php | 67 ++++++++++++++ tests/Feature/CardCorrectionScreenTest.php | 79 ++++++++++++++++ tests/Integration/CardCorrectionTest.php | 100 +++++++++++++++++++++ views/merchant/customer.php | 29 ++++++ views/partials/flash.php | 6 ++ 7 files changed, 308 insertions(+), 1 deletion(-) create mode 100644 tests/Feature/CardCorrectionScreenTest.php create mode 100644 tests/Integration/CardCorrectionTest.php diff --git a/CLAUDE.md b/CLAUDE.md index 3bfeff4..0106999 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -44,7 +44,7 @@ Banco: `mysql -u root -p < database/schema.sql` em instalação nova. Banco já **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. -**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. 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. **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. diff --git a/src/Controllers/MerchantController.php b/src/Controllers/MerchantController.php index ede1054..ee7a7a7 100644 --- a/src/Controllers/MerchantController.php +++ b/src/Controllers/MerchantController.php @@ -625,6 +625,32 @@ private function handleCustomer($merchantId) { redirect('merchant/customer', ['phone' => $phone, 'success' => 'consentimento_registrado']); } + // corrigir o nome dado a esta loja (task 43) + if (($_POST['action'] ?? '') === 'rename') { + $name = trim((string)($_POST['name'] ?? '')); + if ($name === '' || mb_strlen($name) > 255) { + redirect('merchant/customer', ['phone' => $phone, 'error' => 'nome_invalido']); + } + $cardModel->rename($card['id'], $merchantId, $name); + redirect('merchant/customer', ['phone' => $phone, 'success' => 'nome_corrigido']); + } + + // cliente trocou de numero (task 43): o cartao desta loja vai para o telefone novo, com saldo e historico. + // telefone que ja tem cartao nesta loja e recusado; o que ele tem em outras lojas nao aparece nem muda. + if (($_POST['action'] ?? '') === 'change_phone') { + $newPhone = PhoneValidator::sanitize($_POST['new_phone'] ?? ''); + if (!PhoneValidator::isValid($newPhone)) { + redirect('merchant/customer', ['phone' => $phone, 'error' => 'telefone_novo_invalido']); + } + if ($newPhone === $phone) { + redirect('merchant/customer', ['phone' => $phone, 'error' => 'telefone_igual']); + } + if ($cardModel->changePhone($card['id'], $merchantId, $newPhone) !== 'ok') { + redirect('merchant/customer', ['phone' => $phone, 'error' => 'telefone_ja_cliente']); + } + redirect('merchant/customer', ['phone' => $newPhone, 'success' => 'telefone_trocado']); + } + // exclusao dos dados a pedido do cliente (LGPD). a confirmacao e obrigatoria: nao tem volta. if (($_POST['action'] ?? '') === 'anonymize') { if (($_POST['confirm'] ?? '') !== '1') { diff --git a/src/Models/LoyaltyCardModel.php b/src/Models/LoyaltyCardModel.php index cafb9e3..be5d0e6 100644 --- a/src/Models/LoyaltyCardModel.php +++ b/src/Models/LoyaltyCardModel.php @@ -233,6 +233,73 @@ public function recordConsent($cardId, string $version) { $stmt->execute([':version' => $version, ':id' => $cardId]); } + // corrige o nome que o cliente deu a esta loja (digitado errado no cadastro rapido). so mexe no cartao + // desta loja: o nome dado em outra loja continua o mesmo. devolve false se o cartao nao for desta loja. + public function rename($cardId, $merchantId, string $name): bool { + $stmt = $this->db->prepare( + 'UPDATE loyalty_cards SET customer_name = :name + WHERE id = :id AND merchant_id = :merchant_id AND anonymized_at IS NULL' + ); + $stmt->execute([':name' => $name, ':id' => $cardId, ':merchant_id' => $merchantId]); + return $stmt->rowCount() > 0 || $this->belongsTo($cardId, $merchantId); + } + + // cliente trocou de numero: o cartao DESTA loja (saldo, historico, nome, consentimento) passa para o + // telefone novo. cartoes do numero antigo em outras lojas nao mudam (cada loja move o seu). + // o telefone antigo some quando nao sobra cartao dele em nenhuma loja (como na exclusao). + // devolve 'ok', 'taken' (o telefone novo ja tem cartao nesta loja) ou 'not_found'. + public function changePhone($cardId, $merchantId, string $newPhone): string { + $this->db->beginTransaction(); + try { + $stmt = $this->db->prepare( + 'SELECT customer_id FROM loyalty_cards + WHERE id = :id AND merchant_id = :merchant_id AND anonymized_at IS NULL + FOR UPDATE' + ); + $stmt->execute([':id' => $cardId, ':merchant_id' => $merchantId]); + $oldCustomerId = $stmt->fetchColumn(); + if ($oldCustomerId === false || $oldCustomerId === null) { + $this->db->rollBack(); + return 'not_found'; + } + + $newCustomerId = (new CustomerModel($this->db))->findOrCreate($newPhone); + if ($newCustomerId === (int)$oldCustomerId) { + $this->db->rollBack(); + return 'ok'; + } + + // o UNIQUE (merchant_id, customer_id) barra o telefone que ja e cliente desta loja + $this->db->prepare('UPDATE loyalty_cards SET customer_id = :customer_id WHERE id = :id') + ->execute([':customer_id' => $newCustomerId, ':id' => $cardId]); + + $this->db->prepare( + 'DELETE FROM customers + WHERE id = :c1 AND NOT EXISTS (SELECT 1 FROM loyalty_cards WHERE customer_id = :c2)' + )->execute([':c1' => $oldCustomerId, ':c2' => $oldCustomerId]); + + $this->db->commit(); + return 'ok'; + } catch (\PDOException $e) { + $this->db->rollBack(); + if (($e->errorInfo[1] ?? null) === 1062) { + return 'taken'; + } + throw $e; + } catch (\Throwable $e) { + $this->db->rollBack(); + throw $e; + } + } + + private function belongsTo($cardId, $merchantId): bool { + $stmt = $this->db->prepare( + 'SELECT 1 FROM loyalty_cards WHERE id = :id AND merchant_id = :merchant_id AND anonymized_at IS NULL' + ); + $stmt->execute([':id' => $cardId, ':merchant_id' => $merchantId]); + return (bool)$stmt->fetchColumn(); + } + // exclusao a pedido do cliente (LGPD), tudo ou nada: // - o cartao perde nome, telefone (customer_id), consentimento e saldo; fica so como numero nos relatorios // - as descricoes de ganho (texto livre do lojista, pode ter dado pessoal) sao trocadas; os resgates diff --git a/tests/Feature/CardCorrectionScreenTest.php b/tests/Feature/CardCorrectionScreenTest.php new file mode 100644 index 0000000..04cbcb1 --- /dev/null +++ b/tests/Feature/CardCorrectionScreenTest.php @@ -0,0 +1,79 @@ +post('merchant/customer', $fields + ['phone' => '11922220002'], true, self::SCREEN)[1]; + } + + public function testCorrigeONome(): void { + $merchant = $this->createMerchant('loja@teste.test'); + $this->createCard($merchant, 'Bai Souza', '11922220002'); + $this->loginAs('loja@teste.test'); + + $this->get(self::SCREEN); + $this->assertStringContainsString('Corrigir nome ou trocar telefone', $this->lastBody); + + $this->assertSame(self::SCREEN . '&error=nome_invalido', $this->send(['action' => 'rename', 'name' => ' '])); + $this->assertSame(self::SCREEN . '&success=nome_corrigido', $this->send(['action' => 'rename', 'name' => ' Bia Souza '])); + + $this->get(self::SCREEN); + $this->assertStringContainsString('
Bia Souza
', $this->lastBody); + } + + public function testTrocaOTelefoneELevaOSaldo(): void { + $merchant = $this->createMerchant('loja@teste.test'); + $card = $this->createCard($merchant, 'Bia', '11922220002'); + (new \App\Models\LoyaltyCardModel($this->db))->addPoints($card, 40, 'Compra'); + $this->loginAs('loja@teste.test'); + + $this->assertSame(self::SCREEN . '&error=telefone_novo_invalido', $this->send(['action' => 'change_phone', 'new_phone' => '123'])); + $this->assertSame(self::SCREEN . '&error=telefone_igual', $this->send(['action' => 'change_phone', 'new_phone' => '(11) 92222-0002'])); + + $this->assertSame( + 'merchant/customer&phone=11955550005&success=telefone_trocado', + $this->send(['action' => 'change_phone', 'new_phone' => '(11) 95555-0005']) + ); + $this->get('merchant/customer&phone=11955550005'); + $this->assertStringContainsString('customer-balance-value">40<', $this->lastBody); + + // o numero antigo virou "telefone novo" para esta loja: cadastro rapido + [, $location] = $this->get('merchant/dashboard&phone=11922220002'); + $this->assertSame('merchant/customer-new&phone=11922220002', $location); + } + + // a recusa so fala desta loja; telefone de cliente de outra loja segue como qualquer numero novo (task 11) + public function testTelefoneDeClienteDestaLojaERecusadoEDeOutraLojaNao(): void { + $loja = $this->createMerchant('loja@teste.test'); + $outra = $this->createMerchant('outra@teste.test'); + $this->createCard($loja, 'Bia', '11922220002'); + $this->createCard($loja, 'Caio', '11933330003'); + $this->createCard($outra, 'Dani', '11944440004'); + $this->loginAs('loja@teste.test'); + + $this->assertSame(self::SCREEN . '&error=telefone_ja_cliente', $this->send(['action' => 'change_phone', 'new_phone' => '11933330003'])); + $this->assertSame( + 'merchant/customer&phone=11944440004&success=telefone_trocado', + $this->send(['action' => 'change_phone', 'new_phone' => '11944440004']) + ); + $this->get('merchant/customer&phone=11944440004'); + $this->assertStringContainsString('Bia', $this->lastBody); + $this->assertStringNotContainsString('Dani', $this->lastBody); + } + + public function testCorrecaoExigeCsrf(): void { + $merchant = $this->createMerchant('loja@teste.test'); + $this->createCard($merchant, 'Bia', '11922220002'); + $this->loginAs('loja@teste.test'); + + [$status] = $this->post('merchant/customer', ['action' => 'rename', 'phone' => '11922220002', 'name' => 'X'], false); + $this->assertSame(403, $status); + $this->assertSame('Bia', $this->scalar('SELECT customer_name FROM loyalty_cards')); + } +} diff --git a/tests/Integration/CardCorrectionTest.php b/tests/Integration/CardCorrectionTest.php new file mode 100644 index 0000000..efa0de7 --- /dev/null +++ b/tests/Integration/CardCorrectionTest.php @@ -0,0 +1,100 @@ +cards = new LoyaltyCardModel($this->db); + $this->loja = $this->createMerchant('loja@teste.test'); + $this->outra = $this->createMerchant('outra@teste.test'); + } + + private function phoneOf(int $cardId): ?string { + $phone = $this->scalar( + 'SELECT c.phone FROM loyalty_cards lc LEFT JOIN customers c ON c.id = lc.customer_id WHERE lc.id = :id', + [':id' => $cardId] + ); + return $phone === false ? null : $phone; + } + + public function testCorrigeONomeSoNestaLoja(): void { + $aqui = $this->createCard($this->loja, 'Bai', '11922220002'); + $la = $this->createCard($this->outra, 'Beatriz', '11922220002'); + + $this->assertTrue($this->cards->rename($aqui, $this->loja, 'Bia')); + + $this->assertSame('Bia', $this->scalar('SELECT customer_name FROM loyalty_cards WHERE id = ?', [$aqui])); + $this->assertSame('Beatriz', $this->scalar('SELECT customer_name FROM loyalty_cards WHERE id = ?', [$la])); + $this->assertFalse($this->cards->rename($aqui, $this->outra, 'Invadido'), 'outra loja nao mexe'); + $this->assertSame('Bia', $this->scalar('SELECT customer_name FROM loyalty_cards WHERE id = ?', [$aqui])); + } + + public function testTrocaOTelefoneComSaldoEHistorico(): void { + $card = $this->createCard($this->loja, 'Bia', '11922220002'); + $this->cards->addPoints($card, 40, 'Compra'); + + $this->assertSame('ok', $this->cards->changePhone($card, $this->loja, '11955550005')); + + $this->assertSame('11955550005', $this->phoneOf($card)); + $moved = $this->cards->findByMerchantAndPhone($this->loja, '11955550005'); + $this->assertSame($card, (int)$moved['id']); + $this->assertSame(40, (int)$moved['current_points']); + $this->assertSame('Bia', $moved['customer_name']); + $this->assertNotNull($moved['consent_at'], 'o consentimento vem junto'); + $this->assertSame(1, (int)$this->scalar('SELECT COUNT(*) FROM points_log WHERE card_id = ?', [$card])); + $this->assertFalse($this->cards->findByMerchantAndPhone($this->loja, '11922220002')); + // numero antigo sem nenhum cartao some da plataforma + $this->assertSame(0, (int)$this->scalar("SELECT COUNT(*) FROM customers WHERE phone = '11922220002'")); + } + + public function testCartaoDoNumeroAntigoEmOutraLojaNaoMuda(): void { + $aqui = $this->createCard($this->loja, 'Bia', '11922220002'); + $la = $this->createCard($this->outra, 'Beatriz', '11922220002'); + + $this->assertSame('ok', $this->cards->changePhone($aqui, $this->loja, '11955550005')); + + $this->assertSame('11922220002', $this->phoneOf($la)); + $this->assertSame(1, (int)$this->scalar("SELECT COUNT(*) FROM customers WHERE phone = '11922220002'")); + } + + // o numero novo ja existe na plataforma (cliente de outra loja): reaproveita o telefone, sem misturar cartoes + public function testTelefoneNovoQueEClienteDeOutraLoja(): void { + $aqui = $this->createCard($this->loja, 'Bia', '11922220002'); + $la = $this->createCard($this->outra, 'Caio', '11955550005'); + + $this->assertSame('ok', $this->cards->changePhone($aqui, $this->loja, '11955550005')); + + $this->assertSame(1, (int)$this->scalar("SELECT COUNT(*) FROM customers WHERE phone = '11955550005'")); + $this->assertSame('Bia', $this->cards->findByMerchantAndPhone($this->loja, '11955550005')['customer_name']); + $this->assertSame('Caio', $this->cards->findByMerchantAndPhone($this->outra, '11955550005')['customer_name']); + $this->assertSame((int)$la, (int)$this->cards->findByMerchantAndPhone($this->outra, '11955550005')['id']); + } + + public function testTelefoneQueJaTemCartaoNestaLojaERecusado(): void { + $bia = $this->createCard($this->loja, 'Bia', '11922220002'); + $this->createCard($this->loja, 'Caio', '11955550005'); + + $this->assertSame('taken', $this->cards->changePhone($bia, $this->loja, '11955550005')); + + $this->assertSame('11922220002', $this->phoneOf($bia)); + $this->assertFalse($this->db->inTransaction()); + } + + public function testCartaoDeOutraLojaOuAnonimizadoNaoTroca(): void { + $card = $this->createCard($this->loja, 'Bia', '11922220002'); + $this->assertSame('not_found', $this->cards->changePhone($card, $this->outra, '11955550005')); + + $this->cards->anonymize($card, $this->loja); + $this->assertSame('not_found', $this->cards->changePhone($card, $this->loja, '11955550005')); + $this->assertSame(0, (int)$this->scalar("SELECT COUNT(*) FROM customers WHERE phone = '11955550005'")); + } +} diff --git a/views/merchant/customer.php b/views/merchant/customer.php index 91548ea..275107d 100644 --- a/views/merchant/customer.php +++ b/views/merchant/customer.php @@ -134,6 +134,35 @@ + +
+ Corrigir nome ou trocar telefone + + + + + +
+ + +
+ + + +
+ + + +
+ + +
+

O saldo e o histórico desta loja passam para o número novo.

+ +
+
+ pra nao ser clicada por engano no balcao ?>
Excluir dados do cliente diff --git a/views/partials/flash.php b/views/partials/flash.php index 66ea366..8339b9a 100644 --- a/views/partials/flash.php +++ b/views/partials/flash.php @@ -24,6 +24,10 @@ 'nome_obrigatorio' => 'Cliente novo: informe o nome para cadastrá-lo.', 'consentimento_obrigatorio' => 'Confirme que o cliente autorizou o cadastro.', 'cliente_nao_encontrado'=> 'Nenhum cliente com esse telefone nesta loja.', + 'nome_invalido' => 'Informe o nome do cliente (até 255 caracteres).', + 'telefone_novo_invalido'=> 'Informe o telefone novo com DDD.', + 'telefone_igual' => 'O telefone novo é igual ao atual.', + 'telefone_ja_cliente' => 'Esse telefone já é de outro cliente desta loja. Nada foi alterado.', 'premio_invalido' => 'Prêmio inválido ou inativo.', 'saldo_insuficiente' => 'Saldo de pontos insuficiente para este prêmio.', 'confirmacao_obrigatoria' => 'Marque a confirmação de que o cliente pediu a exclusão.', @@ -51,6 +55,8 @@ 'resgate_realizado' => 'Resgate realizado com sucesso.', 'consentimento_registrado' => 'Consentimento registrado.', 'cliente_excluido' => 'Dados do cliente excluídos.', + 'nome_corrigido' => 'Nome corrigido.', + 'telefone_trocado' => 'Telefone trocado. Saldo e histórico vieram junto.', 'lancamento_estornado' => 'Lançamento estornado. Os pontos saíram do saldo.', 'resgate_estornado' => 'Resgate estornado. Os pontos voltaram ao saldo.', 'regra_salva' => 'Regra de pontos salva.', From 0f6cdfe5b42bcaabf8819694a1638d4de57f4590 Mon Sep 17 00:00:00 2001 From: "Emanuel G. Sousa" Date: Tue, 6 Oct 2026 16:51:14 -0300 Subject: [PATCH 05/15] feat(acessibilidade): estilos em classe, botao carregando e campo com erro marcado - style="" das views vira classe (a barra de progresso usa passos de 5%) - src/ts/forms.ts: botao mostra carregando e o formulario nao e enviado duas vezes; o campo apontado pelo erro fica marcado, com aria-invalid e foco - foco do teclado sempre visivel Task 18. Co-Authored-By: Claude Opus 5.5 --- CLAUDE.md | 2 + public/css/app.css | 23 +++++ public/css/auth.css | 7 +- public/css/components.css | 57 +++++++++++ public/js/forms.js | 69 +++++++++++++ src/ts/forms.ts | 70 +++++++++++++ tests/Feature/AccessibilityTest.php | 58 +++++++++++ tests/Feature/RewardProgressScreenTest.php | 2 +- tests/js/forms.test.js | 109 +++++++++++++++++++++ views/auth/merchant/login.php | 1 + views/auth/merchant/register.php | 5 +- views/customer/balance.php | 1 + views/partials/flash.php | 30 +++++- views/partials/merchant-footer.php | 1 + views/partials/reward-progress.php | 3 +- 15 files changed, 431 insertions(+), 7 deletions(-) create mode 100644 public/js/forms.js create mode 100644 src/ts/forms.ts create mode 100644 tests/Feature/AccessibilityTest.php create mode 100644 tests/js/forms.test.js diff --git a/CLAUDE.md b/CLAUDE.md index 0106999..ef680c0 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -18,6 +18,7 @@ find src views public tests bin -name "*.php" -exec php -l {} \; # checagem de composer test # phpunit (unidade + integração + fluxo); recria o banco fidelityx_test 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 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) @@ -72,6 +73,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=""` nas views (a CSP bloqueia): use classe em `public/css`. ## Backlog diff --git a/public/css/app.css b/public/css/app.css index c0179fe..44c86ba 100644 --- a/public/css/app.css +++ b/public/css/app.css @@ -585,6 +585,29 @@ span.btn-secondary { border-radius: 999px; } +/* largura da barra em passos de 5% (reward-progress.php): sem atributo style, que a CSP bloquearia */ +.progress-w-0 { width: 0%; } +.progress-w-5 { width: 5%; } +.progress-w-10 { width: 10%; } +.progress-w-15 { width: 15%; } +.progress-w-20 { width: 20%; } +.progress-w-25 { width: 25%; } +.progress-w-30 { width: 30%; } +.progress-w-35 { width: 35%; } +.progress-w-40 { width: 40%; } +.progress-w-45 { width: 45%; } +.progress-w-50 { width: 50%; } +.progress-w-55 { width: 55%; } +.progress-w-60 { width: 60%; } +.progress-w-65 { width: 65%; } +.progress-w-70 { width: 70%; } +.progress-w-75 { width: 75%; } +.progress-w-80 { width: 80%; } +.progress-w-85 { width: 85%; } +.progress-w-90 { width: 90%; } +.progress-w-95 { width: 95%; } +.progress-w-100 { width: 100%; } + /* cartaz: folha clara (--paper) com nome da loja e qr code; centralizado e legivel de longe */ .poster { max-width: 520px; diff --git a/public/css/auth.css b/public/css/auth.css index 84256cc..fb0714a 100644 --- a/public/css/auth.css +++ b/public/css/auth.css @@ -94,4 +94,9 @@ .auth-footer { font-size: 1rem; /* Rodapé mais visível */ } -} \ No newline at end of file +} + +/* cadastro: formulario com mais campos, card mais largo (antes era style="" na view) */ +.auth-container.auth-container-wide { + max-width: 600px; +} diff --git a/public/css/components.css b/public/css/components.css index b33a781..7dcd7fc 100644 --- a/public/css/components.css +++ b/public/css/components.css @@ -170,3 +170,60 @@ border-color: var(--warning); color: var(--warning-text); } + +/* acessibilidade (task 18) ------------------------------------------------ */ + +/* foco do teclado sempre visivel (inputs e selects ja tem o anel em :focus) */ +a:focus-visible, +button:focus-visible, +summary:focus-visible, +[tabindex]:focus-visible, +input[type="checkbox"]:focus-visible, +input[type="radio"]:focus-visible { + outline: 3px solid var(--primary); + outline-offset: 2px; +} + +/* campo apontado pela mensagem de erro (public/js/forms.js) */ +.form-group input.is-invalid, +.form-group select.is-invalid, +.form-group textarea.is-invalid, +input.is-invalid { + border-color: var(--error); + box-shadow: 0 0 0 3px var(--error-soft); +} + +/* botao enviando: texto apagado + girador, sem aceitar outro clique */ +button.is-loading { + position: relative; + color: transparent !important; + pointer-events: none; +} + +button.is-loading::after { + content: ""; + position: absolute; + inset: 0; + margin: auto; + width: 1.1em; + height: 1.1em; + border: 2px solid currentColor; + border-color: var(--text-main) transparent var(--text-main) transparent; + border-radius: 50%; + animation: btn-spin 0.8s linear infinite; +} + +.btn-primary.is-loading::after { + border-color: var(--on-primary) transparent var(--on-primary) transparent; +} + +@keyframes btn-spin { + to { transform: rotate(360deg); } +} + +@media (prefers-reduced-motion: reduce) { + button.is-loading::after { animation-duration: 2.4s; } +} + +/* espaco que antes vinha em style="" na view */ +.btn-spaced { margin-top: 1rem; } diff --git a/public/js/forms.js b/public/js/forms.js new file mode 100644 index 0000000..f93501d --- /dev/null +++ b/public/js/forms.js @@ -0,0 +1,69 @@ +"use strict"; +// comportamento comum dos formularios (so apresentacao: sem js tudo continua funcionando). +// - o botao de enviar mostra "carregando" e o formulario nao e enviado duas vezes (clique duplo no balcao +// lancaria os pontos em dobro) +// - o campo apontado pela mensagem de erro (data-fields no .alert-error, ver views/partials/flash.php) +// fica marcado, ligado a mensagem e com o foco +// sem import/export de proposito: o arquivo e carregado como script comum (', $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( + ' + diff --git a/views/partials/flash.php b/views/partials/flash.php index 8339b9a..1dbe168 100644 --- a/views/partials/flash.php +++ b/views/partials/flash.php @@ -66,9 +66,35 @@ ], ]; +// campos que cada erro aponta (pelo name do input): o public/js/forms.js marca, liga a mensagem e poe o foco. +// erro de login nao aponta campo de proposito (nao diz se o errado foi o e-mail ou a senha). +$flashFields = [ + 'telefone_invalido' => ['phone'], + 'email_invalido' => ['email'], + 'documento_invalido' => ['document'], + 'senha_curta' => ['password', 'password_confirm', 'new_password', 'new_password_confirm'], + 'senha_longa' => ['password', 'password_confirm', 'new_password', 'new_password_confirm'], + 'senhas_diferentes' => ['password_confirm', 'new_password_confirm'], + 'senha_igual' => ['new_password', 'new_password_confirm'], + 'senha_atual_incorreta' => ['current_password'], + 'nome_obrigatorio' => ['name'], + 'nome_invalido' => ['name'], + 'consentimento_obrigatorio' => ['consent'], + 'confirmacao_obrigatoria' => ['confirm'], + 'pontos_invalidos' => ['points'], + 'valor_invalido' => ['amount'], + 'valor_sem_pontos' => ['amount'], + 'valor_pontos_demais' => ['amount'], + 'regra_invalida' => ['rule'], + 'telefone_novo_invalido' => ['new_phone'], + 'telefone_igual' => ['new_phone'], + 'telefone_ja_cliente' => ['new_phone'], +]; + foreach (['error', 'success'] as $flashType): $flashCode = $_GET[$flashType] ?? null; - if (is_string($flashCode) && isset($flashMessages[$flashType][$flashCode])): ?> -
+ if (is_string($flashCode) && isset($flashMessages[$flashType][$flashCode])): + $fields = $flashType === 'error' ? ($flashFields[$flashCode] ?? []) : []; ?> +
data-fields="">
+ diff --git a/views/partials/reward-progress.php b/views/partials/reward-progress.php index 3de49fe..cb2d1f8 100644 --- a/views/partials/reward-progress.php +++ b/views/partials/reward-progress.php @@ -16,6 +16,7 @@
-
+ +
From dd37bd47e8464cde8a5c35c326a5aff9c710ec77 Mon Sep 17 00:00:00 2001 From: "Emanuel G. Sousa" Date: Tue, 6 Oct 2026 16:57:34 -0300 Subject: [PATCH 06/15] feat(seguranca): csp completa sem nada inline e hsts em https - scripts inline viram src/ts/nav.ts e src/ts/customer.ts (compilados em public/js) - onsubmit/onclick viram data-confirm (forms.js) e data-print (nav.js) - RequestGuard manda CSP com default-src 'self' e, em https, Strict-Transport-Security Task 48. Co-Authored-By: Claude Opus 5.5 --- CLAUDE.md | 5 +- docs/operacao.md | 1 + public/index.php | 1 + public/js/customer.js | 94 +++++++++++++++++++++++++++ public/js/forms.js | 10 ++- public/js/nav.js | 22 +++++++ src/Support/RequestGuard.php | 19 +++++- src/ts/customer.ts | 89 +++++++++++++++++++++++++ src/ts/forms.ts | 10 ++- src/ts/nav.ts | 24 +++++++ tests/Feature/PosterTest.php | 2 +- tests/Feature/SecurityHeadersTest.php | 54 ++++++++++++++- tests/Support/HttpTestCase.php | 14 +++- tests/js/customer.test.js | 18 +++++ tests/js/forms.test.js | 28 ++++++-- views/merchant/customer.php | 77 +--------------------- views/merchant/points-rule.php | 2 +- views/merchant/poster.php | 2 +- views/merchant/rewards.php | 2 +- views/merchant/statement.php | 2 +- views/partials/merchant-header.php | 12 +--- 21 files changed, 386 insertions(+), 102 deletions(-) create mode 100644 public/js/customer.js create mode 100644 public/js/nav.js create mode 100644 src/ts/customer.ts create mode 100644 src/ts/nav.ts create mode 100644 tests/js/customer.test.js diff --git a/CLAUDE.md b/CLAUDE.md index ef680c0..52e45d5 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -19,6 +19,7 @@ composer test # phpunit (unidade + integração + fluxo 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) @@ -49,7 +50,7 @@ Banco: `mysql -u root -p < database/schema.sql` em instalação nova. Banco já **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. +**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`. @@ -73,7 +74,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=""` nas views (a CSP bloqueia): use classe em `public/css`. +- `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, $m); + $this->assertSame(['/js/nav.js', '/js/customer.js', '/js/masks.js', '/js/forms.js'], $m[1]); + foreach ($m[1] as $src) { + $this->assertFileExists(dirname(__DIR__, 2) . '/public' . $src); + } + + $this->get('merchant/poster'); + $this->assertStringContainsString('data-print>', $this->lastBody); + } + + // hsts so por https (aqui, atras do proxy confiavel que avisa pelo X-Forwarded-Proto) + public function testHstsSoEmHttps(): void { + $this->withHeaders(['X-Forwarded-Proto: https']); + $this->get('home'); + $this->assertSame('max-age=31536000', $this->header('Strict-Transport-Security')); + + $this->withHeaders([]); + $this->get('home'); + $this->assertNull($this->header('Strict-Transport-Security')); + } + // task 47: nome, telefone e saldo nao ficam no cache (Voltar depois do logout, computador compartilhado) public function testPainelEConsultaDeSaldoSaemComNoStore(): void { $merchant = $this->createMerchant('loja@teste.test'); diff --git a/tests/Support/HttpTestCase.php b/tests/Support/HttpTestCase.php index 6a873fb..c199be6 100644 --- a/tests/Support/HttpTestCase.php +++ b/tests/Support/HttpTestCase.php @@ -15,6 +15,8 @@ abstract class HttpTestCase extends DatabaseTestCase { protected array $lastHeaders = []; // ip do cliente simulado nas proximas requisicoes (null = o ip real da conexao, 127.0.0.1) private ?string $clientIp = null; + // cabecalhos extras das proximas requisicoes (ex.: X-Forwarded-Proto de um proxy https) + private array $extraHeaders = []; public static function setUpBeforeClass(): void { $port = self::freePort(); @@ -63,6 +65,12 @@ protected function setUp(): void { parent::setUp(); $this->cookieJar = tempnam(sys_get_temp_dir(), 'fx-cookie'); $this->clientIp = null; + $this->extraHeaders = []; + } + + // as proximas requisicoes levam estes cabecalhos (o servidor de teste confia no 127.0.0.1 como proxy) + protected function withHeaders(array $headers): void { + $this->extraHeaders = $headers; } // as proximas requisicoes chegam como se viessem deste ip (outra pessoa, em outra rede). @@ -148,8 +156,12 @@ private function request(string $method, string $route, array $fields = []): arr curl_setopt($ch, CURLOPT_POST, true); curl_setopt($ch, CURLOPT_POSTFIELDS, http_build_query($fields)); } + $headers = $this->extraHeaders; if ($this->clientIp !== null) { - curl_setopt($ch, CURLOPT_HTTPHEADER, ['X-Forwarded-For: ' . $this->clientIp]); + $headers[] = 'X-Forwarded-For: ' . $this->clientIp; + } + if ($headers !== []) { + curl_setopt($ch, CURLOPT_HTTPHEADER, $headers); } $body = curl_exec($ch); diff --git a/tests/js/customer.test.js b/tests/js/customer.test.js new file mode 100644 index 0000000..ddeba0a --- /dev/null +++ b/tests/js/customer.test.js @@ -0,0 +1,18 @@ +// testa a conta de valor da compra do public/js/customer.js (compilado de src/ts/customer.ts) sem navegador: +// node tests/js/customer.test.js. tem que bater com o Money::toCents do servidor (tests/Unit/MoneyTest.php). +const vm = require('vm'), fs = require('fs'), assert = require('assert'), path = require('path'); +const ctx = { + document: { querySelectorAll: () => [], getElementById: () => null }, + setTimeout: () => {}, +}; +vm.createContext(ctx); +vm.runInContext(fs.readFileSync(path.join(__dirname, '../../public/js/customer.js'), 'utf8') + ';this.c=amountToCents;', ctx); + +const cases = [ + ['12', 1200], ['12,90', 1290], ['12,9', 1290], ['12.90', 1290], ['1.234', 123400], ['1.234,56', 123456], + [' R$ 12,90 ', 1290], ['0,01', 1], [',50', 50], + ['', null], ['abc', null], ['12,345', null], ['1,2,3', null], ['9999999999', null], + ['-10', null], ['R$ -12,90', null], +]; +for (const [input, cents] of cases) assert.strictEqual(ctx.c(input), cents, 'amountToCents(' + input + ')'); +console.log('ok', cases.length); diff --git a/tests/js/forms.test.js b/tests/js/forms.test.js index e6a8b59..dc0f7ed 100644 --- a/tests/js/forms.test.js +++ b/tests/js/forms.test.js @@ -30,12 +30,16 @@ function form(buttons) { return f; } -function load({ forms = [], fields = [], alert = null }) { +function load({ forms = [], fields = [], alert = null, answer = true }) { const timers = []; const pageshow = []; + const asked = []; const ctx = { setTimeout: (fn) => timers.push(fn), - window: { addEventListener: (type, fn) => pageshow.push(fn) }, + window: { + addEventListener: (type, fn) => pageshow.push(fn), + confirm: (question) => { asked.push(question); return answer; }, + }, document: { querySelectorAll: (sel) => { if (sel === 'form') return forms; @@ -47,7 +51,7 @@ function load({ forms = [], fields = [], alert = null }) { }; vm.createContext(ctx); vm.runInContext(fs.readFileSync(path.join(__dirname, '../../public/js/forms.js'), 'utf8'), ctx); - return { runTimers: () => timers.splice(0).forEach((fn) => fn()), pageshow: (e) => pageshow.forEach((fn) => fn(e)) }; + return { asked, runTimers: () => timers.splice(0).forEach((fn) => fn()), pageshow: (e) => pageshow.forEach((fn) => fn(e)) }; } let checks = 0; @@ -76,7 +80,7 @@ const check = (cond, msg) => { assert.ok(cond, msg); checks++; }; check(f.submit() === false, 'pode enviar de novo'); } -// envio cancelado antes (confirm com Cancelar) nao trava o formulario +// envio cancelado antes por outro script nao trava o formulario { const send = element({ type: 'submit' }); const f = form([send]); @@ -85,6 +89,22 @@ const check = (cond, msg) => { assert.ok(cond, msg); checks++; }; check(!send.classList.contains('is-loading') && f.dataset.submitting === undefined, 'cancelado nao trava'); } +// data-confirm (task 48, no lugar do onsubmit): Cancelar nao envia nem trava; OK envia +{ + const send = element({ type: 'submit' }); + const f = form([send]); + f.dataset.confirm = 'Excluir este prêmio?'; + const page = load({ forms: [f], answer: false }); + check(f.submit() === true, 'cancelar barra o envio'); + check(page.asked[0] === 'Excluir este prêmio?', 'pergunta o texto do data-confirm'); + check(!send.classList.contains('is-loading'), 'cancelado nao trava'); + + const ok = form([element({ type: 'submit' })]); + ok.dataset.confirm = 'Estornar?'; + load({ forms: [ok], answer: true }); + check(ok.submit() === false, 'ok envia'); +} + // erro: o campo apontado pela mensagem fica marcado, ligado a ela e com foco; hidden nao conta { const alert = element(); diff --git a/views/merchant/customer.php b/views/merchant/customer.php index 275107d..c5fcbdc 100644 --- a/views/merchant/customer.php +++ b/views/merchant/customer.php @@ -182,81 +182,6 @@ - + diff --git a/views/merchant/points-rule.php b/views/merchant/points-rule.php index bc4bf17..2d4cf89 100644 --- a/views/merchant/points-rule.php +++ b/views/merchant/points-rule.php @@ -26,7 +26,7 @@
+ data-confirm="Remover a regra? No balcão, os pontos voltam a ser digitados direto."> diff --git a/views/merchant/poster.php b/views/merchant/poster.php index 4d3eb1b..f589ddc 100644 --- a/views/merchant/poster.php +++ b/views/merchant/poster.php @@ -4,7 +4,7 @@

Imprima e deixe no balcão: o cliente aponta a câmera do celular e vê os pontos dele, sem instalar nada. Na janela de impressão, escolha "Salvar como PDF" se quiser o arquivo.

- +
diff --git a/views/merchant/rewards.php b/views/merchant/rewards.php index 3dc323e..8c4c7b0 100644 --- a/views/merchant/rewards.php +++ b/views/merchant/rewards.php @@ -68,7 +68,7 @@
+ data-confirm="Excluir este prêmio? Se ele já foi resgatado, será só desativado."> diff --git a/views/merchant/statement.php b/views/merchant/statement.php index a6835ff..fa0a63a 100644 --- a/views/merchant/statement.php +++ b/views/merchant/statement.php @@ -37,7 +37,7 @@ ? 'Estornar este resgate? Os pontos voltam ao saldo do cliente.' : 'Estornar este lançamento? Os pontos saem do saldo do cliente.'; ?> + data-confirm=""> diff --git a/views/partials/merchant-header.php b/views/partials/merchant-header.php index dac5f79..783282d 100644 --- a/views/partials/merchant-header.php +++ b/views/partials/merchant-header.php @@ -20,8 +20,8 @@ - - + +
-

From 3ee18153a3e74a78f6316b0b9f9a19f6cb811eea Mon Sep 17 00:00:00 2001 From: "Emanuel G. Sousa" Date: Tue, 6 Oct 2026 17:07:18 -0300 Subject: [PATCH 07/15] feat(relatorios): filtro por periodo e exportacao em csv - indicadores e historico por periodo (hoje, 7 dias, mes, intervalo); o saldo em circulacao continua o atual - merchant/export baixa o historico do periodo ou a lista de clientes em csv (; + BOM, protegido contra injecao de formula), so desta loja Task 59. Co-Authored-By: Claude Opus 5.5 --- CLAUDE.md | 2 +- public/css/app.css | 17 ++++ public/index.php | 1 + src/Controllers/MerchantController.php | 55 +++++++++++- src/Models/PointsLogModel.php | 46 +++++++--- src/Support/Csv.php | 36 ++++++++ src/Support/Period.php | 85 ++++++++++++++++++ tests/Feature/ReportsPeriodTest.php | 114 +++++++++++++++++++++++++ tests/Unit/CsvTest.php | 31 +++++++ tests/Unit/PeriodTest.php | 68 +++++++++++++++ views/merchant/customers.php | 1 + views/merchant/reports.php | 35 +++++++- 12 files changed, 472 insertions(+), 19 deletions(-) create mode 100644 src/Support/Csv.php create mode 100644 src/Support/Period.php create mode 100644 tests/Feature/ReportsPeriodTest.php create mode 100644 tests/Unit/CsvTest.php create mode 100644 tests/Unit/PeriodTest.php diff --git a/CLAUDE.md b/CLAUDE.md index 52e45d5..1093913 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -46,7 +46,7 @@ Banco: `mysql -u root -p < database/schema.sql` em instalação nova. Banco já **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. -**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. +**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. diff --git a/public/css/app.css b/public/css/app.css index 44c86ba..8319378 100644 --- a/public/css/app.css +++ b/public/css/app.css @@ -870,3 +870,20 @@ span.btn-secondary { justify-content: space-between; } } + +/* filtro de periodo dos relatorios (task 59): campos lado a lado a partir do tablet */ +.period-form { + display: grid; + gap: 0 1rem; + align-items: end; +} + +@media (min-width: 640px) { + .period-form { + grid-template-columns: repeat(3, minmax(0, 1fr)) auto; + } + + .period-form .btn-secondary { + margin-bottom: 1.5rem; + } +} diff --git a/public/index.php b/public/index.php index 9d5cf77..74d53f8 100644 --- a/public/index.php +++ b/public/index.php @@ -104,6 +104,7 @@ 'poster' => $controller->renderPoster(), 'points-rule' => $controller->renderPointsRule(), 'reports' => $controller->renderReports(), + 'export' => $controller->renderExport(), 'rewards' => $controller->renderRewards(), 'reward-edit' => $controller->renderRewardEdit(), 'customers' => $controller->renderCustomers(), diff --git a/src/Controllers/MerchantController.php b/src/Controllers/MerchantController.php index ee7a7a7..7be1792 100644 --- a/src/Controllers/MerchantController.php +++ b/src/Controllers/MerchantController.php @@ -12,6 +12,8 @@ use App\Support\Money; use App\Support\Paginator; use App\Support\PasswordPolicy; +use App\Support\Period; +use App\Support\Csv; use App\Support\Privacy; use App\Support\QrSvg; use App\Support\RateLimiter; @@ -40,6 +42,8 @@ class MerchantController { private const MAX_POINTS_PER_ENTRY = 10000; private const PER_PAGE = 20; + // a exportacao le o banco em blocos, pra loja grande nao carregar tudo na memoria de uma vez + private const EXPORT_CHUNK = 500; // confirmacao do lancamento: o botao Desfazer fica UNDO_SECONDS na tela; o aviso so aparece // se o lancamento tiver ate UNDO_BANNER_SECONDS (o estorno em si vale por 24 h, pelo extrato) private const UNDO_SECONDS = 30; @@ -292,20 +296,63 @@ public function renderCustomer() { ]); } - // relatorios: indicadores da loja + historico de todas as movimentacoes, paginado + // relatorios: indicadores da loja + historico das movimentacoes, paginado, os dois pelo periodo escolhido (task 59) public function renderReports() { $merchantId = $this->authGuard(); + $period = Period::fromQuery($_GET); $logModel = new PointsLogModel($this->db); - $paginator = new Paginator($logModel->countByMerchant($merchantId), $_GET['page'] ?? 1, self::PER_PAGE); + $paginator = new Paginator($logModel->countByMerchant($merchantId, $period), $_GET['page'] ?? 1, self::PER_PAGE); View::render('merchant/reports', [ - 'stats' => $logModel->statsByMerchant($merchantId), - 'entries' => $logModel->pageByMerchant($merchantId, $paginator->perPage, $paginator->offset()), + 'stats' => $logModel->statsByMerchant($merchantId, $period), + 'entries' => $logModel->pageByMerchant($merchantId, $paginator->perPage, $paginator->offset(), $period), 'paginator' => $paginator, + 'period' => $period, ]); } + // planilha (csv) do historico do periodo ou da lista de clientes (com a busca), so desta loja (task 59) + public function renderExport() { + $merchantId = $this->authGuard(); + $today = date('Y-m-d'); + + if (($_GET['tipo'] ?? '') === 'clientes') { + $search = mb_substr(trim((string)($_GET['q'] ?? '')), 0, 100); + $cardModel = new LoyaltyCardModel($this->db); + Csv::sendHeaders("clientes-$today.csv"); + echo Csv::BOM . Csv::line(['Cliente', 'Telefone', 'Saldo', 'Total acumulado', 'Última visita']); + for ($offset = 0; $rows = $cardModel->searchByMerchant($merchantId, $search, self::EXPORT_CHUNK, $offset); $offset += self::EXPORT_CHUNK) { + foreach ($rows as $row) { + echo Csv::line([ + (string)$row['name'], format_phone($row['phone']), (int)$row['current_points'], + (int)$row['total_accumulated'], format_datetime($row['last_use_at']), + ]); + } + } + return; + } + + $period = Period::fromQuery($_GET); + $logModel = new PointsLogModel($this->db); + Csv::sendHeaders("historico-$today.csv"); + echo Csv::BOM . Csv::line(['Data', 'Cliente', 'Telefone', 'Tipo', 'Pontos', 'Descrição']); + for ($offset = 0; $rows = $logModel->pageByMerchant($merchantId, self::EXPORT_CHUNK, $offset, $period); $offset += self::EXPORT_CHUNK) { + foreach ($rows as $row) { + [$label, , $sign] = log_type_view($row['type'], $row['reversed_type'] ?? null); + $anonymized = $row['phone'] === null; + echo Csv::line([ + format_datetime($row['created_at']), + $anonymized ? 'Cliente excluído' : (string)$row['customer_name'], + $anonymized ? '' : format_phone($row['phone']), + $label, + $sign === '+' ? (int)$row['quantity'] : -(int)$row['quantity'], + (string)$row['description'], + ]); + } + } + } + // extrato do cliente: todas as movimentacoes dele nesta loja, paginadas. // o cartao e buscado pelo lojista da sessao + telefone, entao nao ha como ver extrato de outra loja. public function renderStatement() { diff --git a/src/Models/PointsLogModel.php b/src/Models/PointsLogModel.php index 8efd8c1..410416f 100644 --- a/src/Models/PointsLogModel.php +++ b/src/Models/PointsLogModel.php @@ -2,6 +2,8 @@ namespace App\Models; +use App\Support\Period; + // leitura do historico de pontos (a escrita fica no LoyaltyCardModel) class PointsLogModel { private $db; @@ -14,20 +16,33 @@ public function recentByMerchant($merchantId, $limit = 10) { return $this->pageByMerchant($merchantId, (int)$limit, 0); } - // uma pagina do historico de movimentacoes da loja, da mais recente pra mais antiga. + // filtro de periodo (task 59) sobre a coluna $column. $tag deixa os placeholders unicos + // (o PDO sem emulacao nao aceita o mesmo placeholder duas vezes na query) + private function periodClause(?Period $period, string $column, string $tag = ''): array { + if ($period === null || !$period->isFiltered()) { + return ['', []]; + } + return [ + " AND $column >= :from$tag AND $column < :to$tag", + [":from$tag" => $period->from, ":to$tag" => $period->to], + ]; + } + + // uma pagina do historico de movimentacoes da loja, da mais recente pra mais antiga (opcionalmente so do periodo). // LEFT JOIN: cartao anonimizado nao tem cliente, mas a movimentacao continua no historico (nome e telefone NULL) - public function pageByMerchant($merchantId, int $limit, int $offset) { + public function pageByMerchant($merchantId, int $limit, int $offset, ?Period $period = null) { + [$where, $params] = $this->periodClause($period, 'pl.created_at'); $sql = 'SELECT pl.type, pl.quantity, pl.description, pl.created_at, lc.customer_name, c.phone, orig.type AS reversed_type FROM points_log pl JOIN loyalty_cards lc ON lc.id = pl.card_id LEFT JOIN customers c ON c.id = lc.customer_id LEFT JOIN points_log orig ON orig.id = pl.reverses_id - WHERE lc.merchant_id = :merchant_id + WHERE lc.merchant_id = :merchant_id' . $where . ' ORDER BY pl.created_at DESC, pl.id DESC LIMIT ' . (int)$limit . ' OFFSET ' . (int)$offset; $stmt = $this->db->prepare($sql); - $stmt->execute([':merchant_id' => $merchantId]); + $stmt->execute([':merchant_id' => $merchantId] + $params); return $stmt->fetchAll(\PDO::FETCH_ASSOC); } @@ -79,33 +94,40 @@ public function countByCard($cardId): int { return (int)$stmt->fetchColumn(); } - public function countByMerchant($merchantId): int { + public function countByMerchant($merchantId, ?Period $period = null): int { + [$where, $params] = $this->periodClause($period, 'pl.created_at'); $sql = 'SELECT COUNT(*) FROM points_log pl JOIN loyalty_cards lc ON lc.id = pl.card_id - WHERE lc.merchant_id = :merchant_id'; + WHERE lc.merchant_id = :merchant_id' . $where; $stmt = $this->db->prepare($sql); - $stmt->execute([':merchant_id' => $merchantId]); + $stmt->execute([':merchant_id' => $merchantId] + $params); return (int)$stmt->fetchColumn(); } // numeros do dashboard. estorno de lancamento desconta dos pontos emitidos; resgate estornado // (task 44) nao conta como resgate, e o estorno dele nao mexe nos pontos emitidos. - public function statsByMerchant($merchantId) { + // com periodo (task 59): clientes = cartoes novos no periodo; pontos emitidos e resgates = movimentacoes + // do periodo (o estorno conta na data dele). o saldo em circulacao e sempre o de agora. + public function statsByMerchant($merchantId, ?Period $period = null) { + [$cardsWhere, $cardsParams] = $this->periodClause($period, 'created_at', 'c'); + [$issuedWhere, $issuedParams] = $this->periodClause($period, 'pl.created_at', 'i'); + [$redeemWhere, $redeemParams] = $this->periodClause($period, 'pl.created_at', 'r'); $sql = "SELECT - (SELECT COUNT(*) FROM loyalty_cards WHERE merchant_id = :m1 AND anonymized_at IS NULL) AS customers, + (SELECT COUNT(*) FROM loyalty_cards WHERE merchant_id = :m1 AND anonymized_at IS NULL$cardsWhere) AS customers, (SELECT COALESCE(SUM(CASE WHEN pl.type = 'earn' THEN pl.quantity ELSE -pl.quantity END), 0) FROM points_log pl JOIN loyalty_cards lc ON lc.id = pl.card_id LEFT JOIN points_log orig ON orig.id = pl.reverses_id WHERE lc.merchant_id = :m2 - AND (pl.type = 'earn' OR (pl.type = 'reversal' AND orig.type = 'earn'))) AS points_issued, + AND (pl.type = 'earn' OR (pl.type = 'reversal' AND orig.type = 'earn'))$issuedWhere) AS points_issued, (SELECT COUNT(*) FROM points_log pl JOIN loyalty_cards lc ON lc.id = pl.card_id WHERE lc.merchant_id = :m3 AND pl.type = 'redeem' - AND NOT EXISTS (SELECT 1 FROM points_log r WHERE r.reverses_id = pl.id)) AS redemptions, + AND NOT EXISTS (SELECT 1 FROM points_log r WHERE r.reverses_id = pl.id)$redeemWhere) AS redemptions, (SELECT COALESCE(SUM(current_points), 0) FROM loyalty_cards WHERE merchant_id = :m4) AS points_balance"; $stmt = $this->db->prepare($sql); - $stmt->execute([':m1' => $merchantId, ':m2' => $merchantId, ':m3' => $merchantId, ':m4' => $merchantId]); + $stmt->execute([':m1' => $merchantId, ':m2' => $merchantId, ':m3' => $merchantId, ':m4' => $merchantId] + + $cardsParams + $issuedParams + $redeemParams); return $stmt->fetch(\PDO::FETCH_ASSOC); } diff --git a/src/Support/Csv.php b/src/Support/Csv.php new file mode 100644 index 0000000..0526aaa --- /dev/null +++ b/src/Support/Csv.php @@ -0,0 +1,36 @@ + 'Desde o início', + 'hoje' => 'Hoje', + '7dias' => 'Últimos 7 dias', + 'mes' => 'Este mês', + 'intervalo' => 'Escolher datas', + ]; + + private const FORMAT = 'Y-m-d H:i:s'; + + private function __construct( + public readonly string $key, + public readonly ?string $from, // inclusivo, 'Y-m-d H:i:s'; null = sem filtro + public readonly ?string $to, // exclusivo + public readonly string $start, // datas do formulario ('' quando nao ha) + public readonly string $end, + public readonly bool $invalid, // intervalo pedido com data errada: cai em "tudo" e a tela avisa + ) { + } + + public static function fromQuery(array $query, ?DateTimeImmutable $now = null): self { + $now = $now ?? new DateTimeImmutable('now'); + $today = $now->setTime(0, 0); + $key = (string)($query['periodo'] ?? 'tudo'); + + return match ($key) { + 'hoje' => self::range('hoje', $today, $today), + '7dias' => self::range('7dias', $today->modify('-6 days'), $today), + 'mes' => self::range('mes', $today->modify('first day of this month'), $today), + 'intervalo' => self::interval((string)($query['de'] ?? ''), (string)($query['ate'] ?? '')), + default => new self('tudo', null, null, '', '', false), + }; + } + + public function isFiltered(): bool { + return $this->from !== null; + } + + // parametros pra manter o periodo nos links (paginacao, exportacao) + public function query(): array { + if (!$this->isFiltered()) { + return []; + } + return $this->key === 'intervalo' + ? ['periodo' => 'intervalo', 'de' => $this->start, 'ate' => $this->end] + : ['periodo' => $this->key]; + } + + // primeiro e ultimo dia, os dois inclusivos + private static function range(string $key, DateTimeImmutable $first, DateTimeImmutable $last): self { + return new self( + $key, + $first->format(self::FORMAT), + $last->modify('+1 day')->format(self::FORMAT), + $first->format('Y-m-d'), + $last->format('Y-m-d'), + false + ); + } + + private static function interval(string $start, string $end): self { + $first = self::date($start); + $last = self::date($end); + if ($first === null || $last === null || $first > $last) { + return new self('tudo', null, null, '', '', true); + } + return self::range('intervalo', $first, $last); + } + + // so AAAA-MM-DD de verdade (2026-02-30 nao vale) + private static function date(string $value): ?DateTimeImmutable { + $date = DateTimeImmutable::createFromFormat('!Y-m-d', $value); + return $date !== false && $date->format('Y-m-d') === $value ? $date : null; + } +} diff --git a/tests/Feature/ReportsPeriodTest.php b/tests/Feature/ReportsPeriodTest.php new file mode 100644 index 0000000..3fa16bf --- /dev/null +++ b/tests/Feature/ReportsPeriodTest.php @@ -0,0 +1,114 @@ +loja = $this->createMerchant('loja@teste.test'); + $this->bia = $this->createCard($this->loja, 'Bia Souza', '11922220002'); + $cards = new LoyaltyCardModel($this->db); + $reward = $this->createReward($this->loja, 'Cafe', 10); + + // mes passado: cadastro antigo, 100 pontos e um resgate + $cards->addPoints($this->bia, 100, 'Compra antiga'); + $cards->redeem($this->bia, $reward); + $this->db->exec("UPDATE points_log SET created_at = NOW() - INTERVAL 40 DAY"); + $this->db->exec("UPDATE loyalty_cards SET created_at = NOW() - INTERVAL 40 DAY"); + + // hoje: cliente novo e 30 pontos + $caio = $this->createCard($this->loja, 'Caio', '11933330003'); + $cards->addPoints($caio, 30, '=1+1'); + } + + public function testIndicadoresEHistoricoDoPeriodo(): void { + $this->loginAs('loja@teste.test'); + + $this->get('merchant/reports&periodo=hoje'); + $this->assertMatchesRegularExpression('#stat-value">1\s*
Clientes novos#', $this->lastBody); + $this->assertMatchesRegularExpression('#stat-value">30
\s*
Pontos emitidos#', $this->lastBody); + $this->assertMatchesRegularExpression('#stat-value">0
\s*
Resgates#', $this->lastBody); + // saldo em circulacao e o de agora: 90 da Bia + 30 do Caio + $this->assertMatchesRegularExpression('#stat-value">120
\s*
Pontos em circula#', $this->lastBody); + $this->assertStringNotContainsString('Compra antiga', $this->lastBody); + $this->assertStringContainsString('
\s*
Clientes<#', $this->lastBody); + $this->assertMatchesRegularExpression('#stat-value">130
\s*
Pontos emitidos#', $this->lastBody); + $this->assertStringContainsString('Compra antiga', $this->lastBody); + } + + public function testDatasInvalidasAvisamEMostramTudo(): void { + $this->loginAs('loja@teste.test'); + + $this->get('merchant/reports&periodo=intervalo&de=2026-09-30&ate=2026-09-01'); + $this->assertStringContainsString('Datas inválidas', $this->lastBody); + $this->assertStringContainsString('Compra antiga', $this->lastBody); + } + + public function testCsvDoHistoricoDoPeriodo(): void { + // outra loja com movimentacao no mesmo dia nao entra + $outra = $this->createMerchant('outra@teste.test'); + (new LoyaltyCardModel($this->db))->addPoints($this->createCard($outra, 'Dani', '11944440004'), 50, 'Outra loja'); + $this->loginAs('loja@teste.test'); + + [$status] = $this->get('merchant/export&tipo=historico&periodo=hoje'); + $this->assertSame(200, $status); + $this->assertSame('text/csv; charset=utf-8', $this->lastHeaders['content-type'][0]); + $this->assertMatchesRegularExpression('/attachment; filename="historico-\d{4}-\d{2}-\d{2}\.csv"/', $this->lastHeaders['content-disposition'][0]); + $this->assertSame('no-store', $this->lastHeaders['cache-control'][0]); + + $this->assertStringStartsWith("\xEF\xBB\xBFData;Cliente;Telefone;Tipo;Pontos;Descrição\r\n", $this->lastBody); + $lines = explode("\r\n", trim($this->lastBody)); + $this->assertCount(2, $lines, 'cabecalho + o lancamento de hoje'); + $this->assertMatchesRegularExpression("#^\d{2}/\d{2}/\d{4} \d{2}:\d{2};Caio;\(11\) 93333-0003;Ganhou;30;'=1\+1$#", $lines[1]); + $this->assertStringNotContainsString('Dani', $this->lastBody); + $this->assertStringNotContainsString('Outra loja', $this->lastBody); + } + + public function testCsvDoHistoricoCompletoComSinalEClienteExcluido(): void { + $cards = new LoyaltyCardModel($this->db); + $cards->anonymize($this->bia, $this->loja); + $this->loginAs('loja@teste.test'); + + $this->get('merchant/export&tipo=historico'); + $this->assertStringContainsString(";Cliente excluído;;Resgatou;-10;Resgate: Cafe\r\n", $this->lastBody); + $this->assertStringContainsString(";Cliente excluído;;Ganhou;100;Cliente excluído\r\n", $this->lastBody); + $this->assertStringNotContainsString('92222', $this->lastBody); + } + + public function testCsvDosClientesComABusca(): void { + $outra = $this->createMerchant('outra@teste.test'); + $this->createCard($outra, 'Beatriz de outra loja', '11922220002'); + $this->loginAs('loja@teste.test'); + + $this->get('merchant/customers'); + $this->assertStringContainsString('merchant%2Fexport&tipo=clientes', $this->lastBody); + + $this->get('merchant/export&tipo=clientes'); + $this->assertMatchesRegularExpression('/filename="clientes-\d{4}-\d{2}-\d{2}\.csv"/', $this->lastHeaders['content-disposition'][0]); + $this->assertStringStartsWith("\xEF\xBB\xBFCliente;Telefone;Saldo;Total acumulado;Última visita\r\n", $this->lastBody); + $this->assertStringContainsString('Bia Souza;(11) 92222-0002;90;100;', $this->lastBody); + $this->assertStringContainsString('Caio;(11) 93333-0003;30;30;', $this->lastBody); + $this->assertStringNotContainsString('outra loja', $this->lastBody); + + $this->get('merchant/export&tipo=clientes&q=bia'); + $this->assertStringContainsString('Bia Souza', $this->lastBody); + $this->assertStringNotContainsString('Caio', $this->lastBody); + } + + public function testExportacaoExigeLogin(): void { + [$status, $location] = $this->get('merchant/export&tipo=clientes'); + $this->assertSame(302, $status); + $this->assertSame('merchant/login&error=sessao_expirada', $location); + } +} diff --git a/tests/Unit/CsvTest.php b/tests/Unit/CsvTest.php new file mode 100644 index 0000000..2b0871e --- /dev/null +++ b/tests/Unit/CsvTest.php @@ -0,0 +1,31 @@ +assertSame("Bia;(11) 92222-0002;30\r\n", Csv::line(['Bia', '(11) 92222-0002', 30])); + } + + public function testAspasQuandoOTextoTemSeparadorAspasOuQuebra(): void { + $this->assertSame("\"a;b\";\"diz \"\"oi\"\"\";\"linha\nnova\"\r\n", Csv::line(['a;b', 'diz "oi"', "linha\nnova"])); + } + + public function testTextoQueParecFormulaNaoViraFormula(): void { + $this->assertSame("'=1+1;'+1;'@SOMA(A1)\r\n", Csv::line(['=1+1', '+1', '@SOMA(A1)'])); + $this->assertSame("\"'=HYPERLINK(\"\"x\"\")\"\r\n", Csv::line(['=HYPERLINK("x")'])); + $this->assertSame("'-10\r\n", Csv::line(['-10']), 'texto, nao numero'); + } + + public function testNumeroInteiroSaiComoNumeroMesmoNegativo(): void { + $this->assertSame("-20;0;1500\r\n", Csv::line([-20, 0, 1500])); + } + + public function testBomDoUtf8(): void { + $this->assertSame("\xEF\xBB\xBF", Csv::BOM); + } +} diff --git a/tests/Unit/PeriodTest.php b/tests/Unit/PeriodTest.php new file mode 100644 index 0000000..575e067 --- /dev/null +++ b/tests/Unit/PeriodTest.php @@ -0,0 +1,68 @@ +now = new DateTimeImmutable('2026-10-06 15:30:00'); + } + + private function period(array $query): Period { + return Period::fromQuery($query, $this->now); + } + + public function testSemPeriodoNaoFiltra(): void { + foreach ([[], ['periodo' => 'tudo'], ['periodo' => 'ontem'], ['periodo' => '']] as $query) { + $period = $this->period($query); + $this->assertFalse($period->isFiltered()); + $this->assertSame('tudo', $period->key); + $this->assertFalse($period->invalid); + $this->assertSame([], $period->query()); + } + } + + public function testHojeSeteDiasEMes(): void { + $hoje = $this->period(['periodo' => 'hoje']); + $this->assertSame(['2026-10-06 00:00:00', '2026-10-07 00:00:00'], [$hoje->from, $hoje->to]); + + // os ultimos 7 dias contam hoje + $semana = $this->period(['periodo' => '7dias']); + $this->assertSame(['2026-09-30 00:00:00', '2026-10-07 00:00:00'], [$semana->from, $semana->to]); + + $mes = $this->period(['periodo' => 'mes']); + $this->assertSame(['2026-10-01 00:00:00', '2026-10-07 00:00:00'], [$mes->from, $mes->to]); + $this->assertSame(['periodo' => 'mes'], $mes->query()); + } + + public function testIntervaloIncluiOsDoisDias(): void { + $period = $this->period(['periodo' => 'intervalo', 'de' => '2026-09-01', 'ate' => '2026-09-30']); + + $this->assertSame(['2026-09-01 00:00:00', '2026-10-01 00:00:00'], [$period->from, $period->to]); + $this->assertSame(['periodo' => 'intervalo', 'de' => '2026-09-01', 'ate' => '2026-09-30'], $period->query()); + + $umDia = $this->period(['periodo' => 'intervalo', 'de' => '2026-09-01', 'ate' => '2026-09-01']); + $this->assertSame(['2026-09-01 00:00:00', '2026-09-02 00:00:00'], [$umDia->from, $umDia->to]); + } + + public function testIntervaloInvalidoCaiEmTudoEAvisa(): void { + $casos = [ + ['de' => '', 'ate' => '2026-09-30'], + ['de' => '2026-09-30', 'ate' => '2026-09-01'], // invertido + ['de' => '2026-02-30', 'ate' => '2026-03-01'], // dia que nao existe + ['de' => '01/09/2026', 'ate' => '30/09/2026'], // formato errado + ['de' => "2026-09-01' OR 1=1", 'ate' => '2026-09-30'], + ]; + foreach ($casos as $datas) { + $period = $this->period(['periodo' => 'intervalo'] + $datas); + $this->assertTrue($period->invalid, json_encode($datas)); + $this->assertFalse($period->isFiltered()); + } + } +} diff --git a/views/merchant/customers.php b/views/merchant/customers.php index 31e64a7..323f592 100644 --- a/views/merchant/customers.php +++ b/views/merchant/customers.php @@ -23,6 +23,7 @@

total ?> total === 1 ? 'cliente' : 'clientes' ?>

+

Baixar planilha (CSV)

diff --git a/views/merchant/reports.php b/views/merchant/reports.php index c76c12d..94557c3 100644 --- a/views/merchant/reports.php +++ b/views/merchant/reports.php @@ -1,10 +1,40 @@ + + + +
+
+ +
+ + +
+
+ + +
+
+ + +
+ + + invalid): ?> + + isFiltered()): ?> +

De start))) ?> a end))) ?>. O saldo em circulação é sempre o de agora.

+ +
-
Clientes
+
isFiltered() ? 'Clientes novos' : 'Clientes' ?>
@@ -22,6 +52,7 @@

Histórico de movimentações

+

Baixar planilha (CSV) do período

Nenhuma movimentação ainda. Os pontos lançados e os resgates aparecem aqui.

@@ -54,7 +85,7 @@ query(); require __DIR__ . '/../partials/pagination.php'; ?> From 7a1cb79b99840078efb9e900ce8672fd3581c74c Mon Sep 17 00:00:00 2001 From: "Emanuel G. Sousa" Date: Tue, 6 Oct 2026 17:11:26 -0300 Subject: [PATCH 08/15] feat(operacao): rota de saude sem sessao que confere o banco ?url=health responde ok/erro em json antes do session_start. O healthcheck do Docker e o monitor externo ficam para a parte de infraestrutura. Task 56 (parte de codigo). Co-Authored-By: Claude Opus 5.5 --- CLAUDE.md | 2 +- docs/operacao.md | 4 ++++ public/index.php | 6 ++++++ src/Support/Health.php | 36 +++++++++++++++++++++++++++++++ tests/Feature/HealthTest.php | 42 ++++++++++++++++++++++++++++++++++++ 5 files changed, 89 insertions(+), 1 deletion(-) create mode 100644 src/Support/Health.php create mode 100644 tests/Feature/HealthTest.php diff --git a/CLAUDE.md b/CLAUDE.md index 1093913..f5b9c14 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -32,7 +32,7 @@ Banco: `mysql -u root -p < database/schema.sql` em instalação nova. Banco já ## 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`, 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. diff --git a/docs/operacao.md b/docs/operacao.md index 57705d8..236ad14 100644 --- a/docs/operacao.md +++ b/docs/operacao.md @@ -61,6 +61,10 @@ TRUSTED_PROXIES=127.0.0.1,172.16.0.0/12 Com o Compose, o proxy no host chega ao container pelo gateway da rede do Docker, normalmente um endereço em `172.16.0.0/12`. +## Rota de saúde + +`GET /index.php?url=health` responde `200 {"status":"ok"}` quando a aplicação consegue falar com o banco e `503 {"status":"erro"}` quando não (o motivo vai para o log, nunca para a resposta). Ela roda antes da sessão, então checar a cada 30 s não cria arquivo no volume de sessões, e sai com `Cache-Control: no-store`. É o endereço para o monitor externo e para o `HEALTHCHECK` do container (task 56; a troca do healthcheck no `Dockerfile` e o monitor com alerta ainda estão pendentes). + ## Migrations A tabela `schema_migrations` guarda o nome de cada arquivo de `database/migrations/` que já rodou no banco. diff --git a/public/index.php b/public/index.php index 74d53f8..a5fe29f 100644 --- a/public/index.php +++ b/public/index.php @@ -30,6 +30,12 @@ ErrorLog::useFile(Env::get('LOG_FILE')); RequestGuard::sendHsts(); +// rota de saude do monitor e do healthcheck: antes da sessao, pra checagem nao criar arquivo de sessao +if (($_GET['url'] ?? '') === 'health') { + \App\Support\Health::respond(); + exit; +} + // cookie de sessao so via http (js nao le), sem envio em POST vindo de outro site e, em https, so por https. // use_strict_mode: id de sessao inventado por quem chega (session fixation) e trocado por um novo. // gc_maxlifetime acompanha a expiracao por inatividade (SessionGuard), senao o PHP apagaria a sessao em 24 min. diff --git a/src/Support/Health.php b/src/Support/Health.php new file mode 100644 index 0000000..00c155b --- /dev/null +++ b/src/Support/Health.php @@ -0,0 +1,36 @@ + 'erro']]; + } + + try { + $db = $connect ? $connect() : Database::connect(); + $db->query('SELECT 1')->fetchColumn(); + } catch (\Throwable $e) { + error_log('[health] banco indisponivel: ' . $e->getMessage()); + return [503, ['status' => 'erro']]; + } + + return [200, ['status' => 'ok']]; + } + + public static function respond(): void { + [$status, $body] = self::check(); + http_response_code($status); + header('Content-Type: application/json; charset=utf-8'); + header('Cache-Control: no-store'); + echo json_encode($body); + } +} diff --git a/tests/Feature/HealthTest.php b/tests/Feature/HealthTest.php new file mode 100644 index 0000000..91e6357 --- /dev/null +++ b/tests/Feature/HealthTest.php @@ -0,0 +1,42 @@ +get('health'); + + $this->assertSame(200, $status); + $this->assertSame('{"status":"ok"}', $this->lastBody); + $this->assertSame('application/json; charset=utf-8', $this->lastHeaders['content-type'][0]); + $this->assertSame('no-store', $this->lastHeaders['cache-control'][0]); + $this->assertArrayNotHasKey('set-cookie', $this->lastHeaders, 'checagem nao cria sessao'); + $this->assertNull($this->cookie('PHPSESSID')); + } + + public function testBancoForaDoArDa503SemDetalhe(): void { + // o motivo vai pro log (aqui, um arquivo temporario em vez da saida do teste) + $log = tempnam(sys_get_temp_dir(), 'fx-health'); + $previous = ini_set('error_log', $log); + try { + [$status, $body] = Health::check(function () { + throw new \PDOException('SQLSTATE[HY000] [2002] Connection refused (host db, usuario root)'); + }); + } finally { + ini_set('error_log', (string)$previous); + } + $this->assertStringContainsString('[health] banco indisponivel', (string)file_get_contents($log)); + @unlink($log); + + $this->assertSame(503, $status); + $this->assertSame(['status' => 'erro'], $body); + } + + public function testBancoNoArDaOk(): void { + $this->assertSame([200, ['status' => 'ok']], Health::check(fn() => $this->db)); + } +} From cec19fb0095a842285eaeecd7ab86d1a88b8389e Mon Sep 17 00:00:00 2001 From: "Emanuel G. Sousa" Date: Tue, 6 Oct 2026 17:16:28 -0300 Subject: [PATCH 09/15] chore(qualidade): phpstan nivel 5 com baseline e composer analyse A baseline guarda so os "might not be defined" das views (variaveis do extract). CI, composer audit no workflow e Dependabot ficam pendentes. Task 51 (parte de codigo). Co-Authored-By: Claude Opus 5.5 --- CLAUDE.md | 4 + composer.json | 6 +- composer.lock | 66 +++++++- phpstan-baseline.neon | 355 ++++++++++++++++++++++++++++++++++++++++++ phpstan.neon | 18 +++ 5 files changed, 446 insertions(+), 3 deletions(-) create mode 100644 phpstan-baseline.neon create mode 100644 phpstan.neon diff --git a/CLAUDE.md b/CLAUDE.md index f5b9c14..4862df2 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -16,6 +16,8 @@ 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 @@ -30,6 +32,8 @@ 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. 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. diff --git a/composer.json b/composer.json index 2cdf14e..de3a943 100644 --- a/composer.json +++ b/composer.json @@ -21,7 +21,8 @@ } }, "scripts": { - "test": "phpunit" + "test": "phpunit", + "analyse": "phpstan analyse --memory-limit=1G --no-progress" }, "config": { "platform": { @@ -29,6 +30,7 @@ } }, "require-dev": { - "phpunit/phpunit": "^10.5" + "phpunit/phpunit": "^10.5", + "phpstan/phpstan": "^2.1" } } diff --git a/composer.lock b/composer.lock index d295679..3ede528 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "f28379f7df4d197bc6a201a68ed42575", + "content-hash": "7d77db38e691409e6915cb467dc82f3a", "packages": [ { "name": "chillerlan/php-qrcode", @@ -878,6 +878,70 @@ }, "time": "2022-02-21T01:04:05+00:00" }, + { + "name": "phpstan/phpstan", + "version": "2.3.0", + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/phpstan/phpstan/zipball/b1f9890d9af53a25c58129e78f0aadd31f90d7f4", + "reference": "b1f9890d9af53a25c58129e78f0aadd31f90d7f4", + "shasum": "" + }, + "require": { + "php": "^7.4|^8.0" + }, + "conflict": { + "phpstan/phpstan-shim": "*" + }, + "bin": [ + "phpstan", + "phpstan.phar" + ], + "type": "library", + "autoload": { + "files": [ + "bootstrap.php" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Ondřej Mirtes" + }, + { + "name": "Markus Staab" + }, + { + "name": "Vincent Langlet" + } + ], + "description": "PHPStan - PHP Static Analysis Tool", + "keywords": [ + "dev", + "static analysis" + ], + "support": { + "docs": "https://phpstan.org/user-guide/getting-started", + "forum": "https://github.com/phpstan/phpstan/discussions", + "issues": "https://github.com/phpstan/phpstan/issues", + "security": "https://github.com/phpstan/phpstan/security/policy", + "source": "https://github.com/phpstan/phpstan-src" + }, + "funding": [ + { + "url": "https://github.com/ondrejmirtes", + "type": "github" + }, + { + "url": "https://github.com/phpstan", + "type": "github" + } + ], + "time": "2026-10-06T06:15:36+00:00" + }, { "name": "phpunit/php-code-coverage", "version": "10.1.16", diff --git a/phpstan-baseline.neon b/phpstan-baseline.neon new file mode 100644 index 0000000..f88dfbc --- /dev/null +++ b/phpstan-baseline.neon @@ -0,0 +1,355 @@ +parameters: + ignoreErrors: + - + message: '#^Variable \$categories might not be defined\.$#' + identifier: variable.undefined + count: 1 + path: views/auth/merchant/register.php + + - + message: '#^Variable \$states might not be defined\.$#' + identifier: variable.undefined + count: 1 + path: views/auth/merchant/register.php + + - + message: '#^Variable \$card might not be defined\.$#' + identifier: variable.undefined + count: 1 + path: views/customer/balance.php + + - + message: '#^Variable \$error might not be defined\.$#' + identifier: variable.undefined + count: 2 + path: views/customer/balance.php + + - + message: '#^Variable \$firstName might not be defined\.$#' + identifier: variable.undefined + count: 1 + path: views/customer/balance.php + + - + message: '#^Variable \$phone might not be defined\.$#' + identifier: variable.undefined + count: 1 + path: views/customer/balance.php + + - + message: '#^Variable \$rewards might not be defined\.$#' + identifier: variable.undefined + count: 1 + path: views/customer/balance.php + + - + message: '#^Variable \$store might not be defined\.$#' + identifier: variable.undefined + count: 1 + path: views/customer/balance.php + + - + message: '#^Variable \$backLabel might not be defined\.$#' + identifier: variable.undefined + count: 1 + path: views/errors/400.php + + - + message: '#^Variable \$backUrl might not be defined\.$#' + identifier: variable.undefined + count: 1 + path: views/errors/400.php + + - + message: '#^Variable \$backLabel might not be defined\.$#' + identifier: variable.undefined + count: 1 + path: views/errors/401.php + + - + message: '#^Variable \$backUrl might not be defined\.$#' + identifier: variable.undefined + count: 1 + path: views/errors/401.php + + - + message: '#^Variable \$backLabel might not be defined\.$#' + identifier: variable.undefined + count: 1 + path: views/errors/403.php + + - + message: '#^Variable \$backUrl might not be defined\.$#' + identifier: variable.undefined + count: 1 + path: views/errors/403.php + + - + message: '#^Variable \$backLabel might not be defined\.$#' + identifier: variable.undefined + count: 1 + path: views/errors/404.php + + - + message: '#^Variable \$backUrl might not be defined\.$#' + identifier: variable.undefined + count: 1 + path: views/errors/404.php + + - + message: '#^Variable \$backLabel might not be defined\.$#' + identifier: variable.undefined + count: 1 + path: views/errors/429.php + + - + message: '#^Variable \$backUrl might not be defined\.$#' + identifier: variable.undefined + count: 1 + path: views/errors/429.php + + - + message: '#^Variable \$backLabel might not be defined\.$#' + identifier: variable.undefined + count: 1 + path: views/errors/500.php + + - + message: '#^Variable \$backUrl might not be defined\.$#' + identifier: variable.undefined + count: 1 + path: views/errors/500.php + + - + message: '#^Variable \$backLabel might not be defined\.$#' + identifier: variable.undefined + count: 1 + path: views/errors/502.php + + - + message: '#^Variable \$backUrl might not be defined\.$#' + identifier: variable.undefined + count: 1 + path: views/errors/502.php + + - + message: '#^Variable \$backLabel might not be defined\.$#' + identifier: variable.undefined + count: 1 + path: views/errors/503.php + + - + message: '#^Variable \$backUrl might not be defined\.$#' + identifier: variable.undefined + count: 1 + path: views/errors/503.php + + - + message: '#^Variable \$backLabel might not be defined\.$#' + identifier: variable.undefined + count: 1 + path: views/errors/default.php + + - + message: '#^Variable \$backUrl might not be defined\.$#' + identifier: variable.undefined + count: 1 + path: views/errors/default.php + + - + message: '#^Variable \$phone might not be defined\.$#' + identifier: variable.undefined + count: 2 + path: views/merchant/customer-new.php + + - + message: '#^Variable \$card might not be defined\.$#' + identifier: variable.undefined + count: 15 + path: views/merchant/customer.php + + - + message: '#^Variable \$launch might not be defined\.$#' + identifier: variable.undefined + count: 1 + path: views/merchant/customer.php + + - + message: '#^Variable \$pointsRule might not be defined\.$#' + identifier: variable.undefined + count: 1 + path: views/merchant/customer.php + + - + message: '#^Variable \$progress might not be defined\.$#' + identifier: variable.undefined + count: 3 + path: views/merchant/customer.php + + - + message: '#^Variable \$rewards might not be defined\.$#' + identifier: variable.undefined + count: 1 + path: views/merchant/customer.php + + - + message: '#^Variable \$undoSeconds might not be defined\.$#' + identifier: variable.undefined + count: 1 + path: views/merchant/customer.php + + - + message: '#^Variable \$customers might not be defined\.$#' + identifier: variable.undefined + count: 1 + path: views/merchant/customers.php + + - + message: '#^Variable \$paginator might not be defined\.$#' + identifier: variable.undefined + count: 2 + path: views/merchant/customers.php + + - + message: '#^Variable \$search might not be defined\.$#' + identifier: variable.undefined + count: 2 + path: views/merchant/customers.php + + - + message: '#^Variable \$ruleCents might not be defined\.$#' + identifier: variable.undefined + count: 1 + path: views/merchant/points-rule.php + + - + message: '#^Variable \$balanceUrl might not be defined\.$#' + identifier: variable.undefined + count: 1 + path: views/merchant/poster.php + + - + message: '#^Variable \$publicCode might not be defined\.$#' + identifier: variable.undefined + count: 1 + path: views/merchant/poster.php + + - + message: '#^Variable \$qrSvg might not be defined\.$#' + identifier: variable.undefined + count: 1 + path: views/merchant/poster.php + + - + message: '#^Variable \$categories might not be defined\.$#' + identifier: variable.undefined + count: 1 + path: views/merchant/profile.php + + - + message: '#^Variable \$maxPassword might not be defined\.$#' + identifier: variable.undefined + count: 3 + path: views/merchant/profile.php + + - + message: '#^Variable \$minPassword might not be defined\.$#' + identifier: variable.undefined + count: 3 + path: views/merchant/profile.php + + - + message: '#^Variable \$profile might not be defined\.$#' + identifier: variable.undefined + count: 11 + path: views/merchant/profile.php + + - + message: '#^Variable \$states might not be defined\.$#' + identifier: variable.undefined + count: 1 + path: views/merchant/profile.php + + - + message: '#^Variable \$entries might not be defined\.$#' + identifier: variable.undefined + count: 1 + path: views/merchant/reports.php + + - + message: '#^Variable \$period might not be defined\.$#' + identifier: variable.undefined + count: 10 + path: views/merchant/reports.php + + - + message: '#^Variable \$stats might not be defined\.$#' + identifier: variable.undefined + count: 4 + path: views/merchant/reports.php + + - + message: '#^Variable \$reward might not be defined\.$#' + identifier: variable.undefined + count: 4 + path: views/merchant/reward-edit.php + + - + message: '#^Variable \$pointsRule might not be defined\.$#' + identifier: variable.undefined + count: 1 + path: views/merchant/rewards.php + + - + message: '#^Variable \$rewards might not be defined\.$#' + identifier: variable.undefined + count: 1 + path: views/merchant/rewards.php + + - + message: '#^Variable \$card might not be defined\.$#' + identifier: variable.undefined + count: 6 + path: views/merchant/statement.php + + - + message: '#^Variable \$entries might not be defined\.$#' + identifier: variable.undefined + count: 1 + path: views/merchant/statement.php + + - + message: '#^Variable \$title might not be defined\.$#' + identifier: variable.undefined + count: 2 + path: views/partials/merchant-header.php + + - + message: '#^Variable \$paginator might not be defined\.$#' + identifier: variable.undefined + count: 8 + path: views/partials/pagination.php + + - + message: '#^Variable \$query might not be defined\.$#' + identifier: variable.undefined + count: 2 + path: views/partials/pagination.php + + - + message: '#^Variable \$route might not be defined\.$#' + identifier: variable.undefined + count: 2 + path: views/partials/pagination.php + + - + message: '#^Variable \$progress might not be defined\.$#' + identifier: variable.undefined + count: 1 + path: views/partials/reward-progress.php + + - + message: '#^Variable \$version might not be defined\.$#' + identifier: variable.undefined + count: 1 + path: views/privacy.php diff --git a/phpstan.neon b/phpstan.neon new file mode 100644 index 0000000..023fef0 --- /dev/null +++ b/phpstan.neon @@ -0,0 +1,18 @@ +# analise estatica (task 51): comeca no nivel 5 com a baseline dos erros que ja existiam. +# erro novo quebra o "composer analyse"; ao corrigir um da baseline, gere de novo: +# composer analyse -- --generate-baseline +# subir de nivel: troque o level, gere a baseline e va limpando aos poucos. +includes: + - phpstan-baseline.neon + +parameters: + level: 5 + paths: + - src + - views + - bin + - public + # as views recebem as variaveis pelo extract() do View::render, que o phpstan nao enxerga: os + # "might not be defined" delas estao na baseline. variavel nova numa view entra la tambem. + bootstrapFiles: + - vendor/autoload.php From 5c0993ba7bb21d42dd937b5a82a5267b71b347c8 Mon Sep 17 00:00:00 2001 From: "Emanuel G. Sousa" Date: Tue, 6 Oct 2026 17:21:47 -0300 Subject: [PATCH 10/15] feat(email): interface de envio com driver de log e sem provedor por padrao MAIL_DRIVER vazio nao manda nada e avisa no log (sem o conteudo); "log" grava o e-mail em MAIL_LOG_FILE para dev e testes. Provedor, SPF e DKIM ficam para a escolha do time. Task 55 (parte de codigo). Co-Authored-By: Claude Opus 5.5 --- .env.example | 6 +++ CLAUDE.md | 2 + docs/operacao.md | 1 + src/Mail/LogMailer.php | 38 ++++++++++++++++ src/Mail/Mailer.php | 9 ++++ src/Mail/MailerFactory.php | 18 ++++++++ src/Mail/Message.php | 13 ++++++ src/Mail/NullMailer.php | 16 +++++++ src/Support/Env.php | 1 + tests/Support/HttpTestCase.php | 13 ++++++ tests/Unit/MailerTest.php | 83 ++++++++++++++++++++++++++++++++++ 11 files changed, 200 insertions(+) create mode 100644 src/Mail/LogMailer.php create mode 100644 src/Mail/Mailer.php create mode 100644 src/Mail/MailerFactory.php create mode 100644 src/Mail/Message.php create mode 100644 src/Mail/NullMailer.php create mode 100644 tests/Unit/MailerTest.php 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 4862df2..6ca502c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -69,6 +69,8 @@ Banco: `mysql -u root -p < database/schema.sql` em instalação nova. Banco já **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. diff --git a/docs/operacao.md b/docs/operacao.md index 236ad14..bd411e4 100644 --- a/docs/operacao.md +++ b/docs/operacao.md @@ -15,6 +15,7 @@ Toda a configuração vem de **variáveis de ambiente**. O arquivo `.env` na rai | `TRUSTED_PROXIES` | não | IPs ou faixas CIDR dos proxies confiáveis, separados por vírgula. Ver [Proxy reverso](#proxy-reverso). | | `LOG_FILE` | não | Arquivo do log de erros. Vazio usa o destino do `php.ini`. | | `BACKUP_DIR`, `BACKUP_KEEP_DAYS`, `MYSQLDUMP_BIN` | não | Backup: pasta (padrão `storage/backups`), dias guardados (padrão `14`, `0` nunca apaga) e caminho do `mysqldump`. | +| `MAIL_DRIVER`, `MAIL_LOG_FILE` | não | E-mail transacional (task 55). Vazio: nenhum e-mail sai e o log registra cada envio perdido (sem o conteúdo). `log`: grava os e-mails inteiros em `MAIL_LOG_FILE` (padrão `storage/mail.log`), **só para desenvolvimento** (tem os links com token). O provedor de produção (SMTP/API, SPF e DKIM do domínio) ainda não foi escolhido. | | `DB_ROOT_PASS`, `APP_PORT` | só no Compose | Senha do root do MySQL do container e porta publicada no host (padrão `8080`). | ## Docker diff --git a/src/Mail/LogMailer.php b/src/Mail/LogMailer.php new file mode 100644 index 0000000..4af3e8d --- /dev/null +++ b/src/Mail/LogMailer.php @@ -0,0 +1,38 @@ + date('c'), + 'to' => $message->to, + 'subject' => $message->subject, + 'body' => $message->body, + ], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) . "\n"; + + $dir = dirname($this->file); + if (!is_dir($dir)) { + @mkdir($dir, 0750, true); + } + if (@file_put_contents($this->file, $line, FILE_APPEND | LOCK_EX) === false) { + error_log('[mail] nao deu pra gravar em MAIL_LOG_FILE: ' . $this->file); + return false; + } + return true; + } + + // mensagens gravadas no arquivo, da mais antiga pra mais nova (usado pelos testes) + public static function read(string $file): array { + $messages = []; + foreach (@file($file, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES) ?: [] as $line) { + $messages[] = json_decode($line, true); + } + return $messages; + } +} diff --git a/src/Mail/Mailer.php b/src/Mail/Mailer.php new file mode 100644 index 0000000..8533aac --- /dev/null +++ b/src/Mail/Mailer.php @@ -0,0 +1,9 @@ + new LogMailer(Env::get('MAIL_LOG_FILE', dirname(__DIR__, 2) . '/storage/mail.log')), + default => new NullMailer(), + }; + } +} diff --git a/src/Mail/Message.php b/src/Mail/Message.php new file mode 100644 index 0000000..14f1176 --- /dev/null +++ b/src/Mail/Message.php @@ -0,0 +1,13 @@ +subject, + substr(hash('sha256', mb_strtolower(trim($message->to))), 0, 12) + )); + return false; + } +} diff --git a/src/Support/Env.php b/src/Support/Env.php index d980fcd..1a61d42 100644 --- a/src/Support/Env.php +++ b/src/Support/Env.php @@ -12,6 +12,7 @@ final class Env { 'DB_HOST', 'DB_PORT', 'DB_NAME', 'DB_USER', 'DB_PASS', 'APP_URL', 'APP_TIMEZONE', 'TRUSTED_PROXIES', 'LOG_FILE', 'BACKUP_DIR', 'BACKUP_KEEP_DAYS', 'MYSQLDUMP_BIN', + 'MAIL_DRIVER', 'MAIL_LOG_FILE', ]; // sem estas o banco nao conecta (DB_PASS pode ser vazia, DB_PORT tem padrao) diff --git a/tests/Support/HttpTestCase.php b/tests/Support/HttpTestCase.php index c199be6..e28df6d 100644 --- a/tests/Support/HttpTestCase.php +++ b/tests/Support/HttpTestCase.php @@ -9,6 +9,8 @@ abstract class HttpTestCase extends DatabaseTestCase { private static $server = null; private static string $baseUrl; + // e-mails "enviados" pelo servidor de teste (MAIL_DRIVER=log), uma linha json por mensagem + private static string $mailLog; private string $cookieJar; protected string $lastBody = ''; // cabecalhos da ultima resposta: nome em minusculas => lista de valores (set-cookie pode vir repetido) @@ -30,6 +32,10 @@ public static function setUpBeforeClass(): void { // o teste e o "proxy confiavel" do servidor: com isso um teste pode se passar por clientes de // ips diferentes mandando X-Forwarded-For (ver fromIp). sem o cabecalho nada muda: vale o 127.0.0.1. $env['TRUSTED_PROXIES'] = '127.0.0.1'; + // e-mail transacional vai pra um arquivo que o teste le (sentMails), nunca pra fora + self::$mailLog = tempnam(sys_get_temp_dir(), 'fx-mail'); + $env['MAIL_DRIVER'] = 'log'; + $env['MAIL_LOG_FILE'] = self::$mailLog; // comando em array: roda o php direto, sem shell no meio (assim o proc_terminate mata o servidor mesmo) self::$server = proc_open( @@ -59,12 +65,14 @@ public static function tearDownAfterClass(): void { proc_close(self::$server); self::$server = null; } + @unlink(self::$mailLog); } protected function setUp(): void { parent::setUp(); $this->cookieJar = tempnam(sys_get_temp_dir(), 'fx-cookie'); $this->clientIp = null; + file_put_contents(self::$mailLog, ''); $this->extraHeaders = []; } @@ -84,6 +92,11 @@ protected function tearDown(): void { @unlink($this->cookieJar); } + // e-mails que o sistema mandou neste teste: [['to' =>, 'subject' =>, 'body' =>, 'at' =>], ...] + protected function sentMails(): array { + return \App\Mail\LogMailer::read(self::$mailLog); + } + // sessao nova (como apagar o cookie no navegador) protected function newSession(): void { @unlink($this->cookieJar); diff --git a/tests/Unit/MailerTest.php b/tests/Unit/MailerTest.php new file mode 100644 index 0000000..cd6254e --- /dev/null +++ b/tests/Unit/MailerTest.php @@ -0,0 +1,83 @@ +envBackup = $_ENV; + $this->log = sys_get_temp_dir() . '/fx-mailer-' . bin2hex(random_bytes(4)) . '.log'; + // o que o codigo manda pro error_log vem pra ca (e nao pra saida do teste) + $this->previousErrorLog = ini_set('error_log', $this->log . '.err'); + } + + protected function tearDown(): void { + $_ENV = $this->envBackup; + ini_set('error_log', (string)$this->previousErrorLog); + @unlink($this->log); + @unlink($this->log . '.err'); + } + + private function message(): Message { + return new Message('ana@teste.test', 'Redefinir senha', "Abra o link:\nhttps://x/?token=segredo"); + } + + public function testSemDriverNenhumEmailSaiEOLogNaoTemOConteudo(): void { + unset($_ENV['MAIL_DRIVER']); + $mailer = MailerFactory::fromEnv(); + + $this->assertInstanceOf(NullMailer::class, $mailer); + $this->assertFalse($mailer->send($this->message())); + + $errors = (string)file_get_contents($this->log . '.err'); + $this->assertStringContainsString('[mail] e-mail nao enviado', $errors); + $this->assertStringContainsString('Redefinir senha', $errors); + $this->assertStringNotContainsString('segredo', $errors); + $this->assertStringNotContainsString('ana@teste.test', $errors); + } + + public function testDriverDesconhecidoNaoEnvia(): void { + $_ENV['MAIL_DRIVER'] = 'smtp'; + $this->assertInstanceOf(NullMailer::class, MailerFactory::fromEnv()); + } + + public function testDriverLogGravaUmaLinhaPorMensagem(): void { + $_ENV['MAIL_DRIVER'] = 'log'; + $_ENV['MAIL_LOG_FILE'] = $this->log; + $mailer = MailerFactory::fromEnv(); + + $this->assertInstanceOf(LogMailer::class, $mailer); + $this->assertTrue($mailer->send($this->message())); + $this->assertTrue($mailer->send(new Message('bia@teste.test', 'Outro', 'Corpo'))); + + $sent = LogMailer::read($this->log); + $this->assertCount(2, $sent); + $this->assertSame('ana@teste.test', $sent[0]['to']); + $this->assertSame('Redefinir senha', $sent[0]['subject']); + $this->assertSame("Abra o link:\nhttps://x/?token=segredo", $sent[0]['body']); + $this->assertSame('bia@teste.test', $sent[1]['to']); + } + + public function testArquivoQueNaoDaPraGravarDevolveFalseEAvisa(): void { + // a "pasta" do arquivo e um arquivo comum: nao da pra criar nem gravar + file_put_contents($this->log, ''); + $mailer = new LogMailer($this->log . '/mail.log'); + + $this->assertFalse($mailer->send($this->message())); + $this->assertStringContainsString('[mail] nao deu pra gravar', (string)file_get_contents($this->log . '.err')); + } + + public function testArquivoSemMensagens(): void { + $this->assertSame([], LogMailer::read($this->log . '-nao-existe')); + } +} From 8f76199fef7b15e716b546e45fb6ca45f2241574 Mon Sep 17 00:00:00 2001 From: "Emanuel G. Sousa" Date: Tue, 6 Oct 2026 17:27:55 -0300 Subject: [PATCH 11/15] feat(senha): recuperacao de senha por e-mail Token aleatorio de uso unico, valido por 1 hora e guardado so como hash (migration 008). O pedido responde igual exista o e-mail ou nao e tem limite por ip e por e-mail; trocar a senha derruba as sessoes abertas. Task 21. Co-Authored-By: Claude Opus 5.5 --- CLAUDE.md | 2 + database/migrations/008_password_resets.sql | 27 ++++ database/schema.sql | 26 +++- docs/db/schema-explanation.md | 8 ++ phpstan-baseline.neon | 24 ++++ public/css/auth.css | 7 + public/index.php | 2 + src/Controllers/MerchantController.php | 100 +++++++++++++ src/Models/PasswordResetModel.php | 87 ++++++++++++ tests/Feature/PasswordResetTest.php | 147 ++++++++++++++++++++ tests/Support/TestDatabase.php | 2 +- views/auth/merchant/forgot.php | 43 ++++++ views/auth/merchant/login.php | 2 + views/auth/merchant/reset.php | 44 ++++++ views/partials/flash.php | 4 + 15 files changed, 523 insertions(+), 2 deletions(-) create mode 100644 database/migrations/008_password_resets.sql create mode 100644 src/Models/PasswordResetModel.php create mode 100644 tests/Feature/PasswordResetTest.php create mode 100644 views/auth/merchant/forgot.php create mode 100644 views/auth/merchant/reset.php diff --git a/CLAUDE.md b/CLAUDE.md index 6ca502c..2e7895a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -54,6 +54,8 @@ Banco: `mysql -u root -p < database/schema.sql` em instalação nova. Banco já **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. +**Esqueci a senha** (task 21) — `merchant/forgot` manda por e-mail um link `merchant/reset&token=...` (`PasswordResetModel`: 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`. diff --git a/database/migrations/008_password_resets.sql b/database/migrations/008_password_resets.sql new file mode 100644 index 0000000..33d4798 --- /dev/null +++ b/database/migrations/008_password_resets.sql @@ -0,0 +1,27 @@ +-- fidelityx - migration 008 (recuperacao de senha por e-mail, task 21) +-- para bancos criados antes desta versao do schema.sql. rodar uma unica vez: +-- php bin/migrate.php +-- (ou, a mao: mysql -u root -p fidelityx < database/migrations/008_password_resets.sql) + +USE fidelityx; + +-- link de redefinicao de senha: o token vai so no e-mail; aqui fica o hash sha-256 dele +-- (quem ler o banco nao consegue usar). vale 1 hora e uma vez so. +CREATE TABLE IF NOT EXISTS password_resets ( + id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY, + + merchant_id BIGINT UNSIGNED NOT NULL, + token_hash CHAR(64) NOT NULL, + expires_at TIMESTAMP NOT NULL, + used_at TIMESTAMP NULL, -- usado ou cancelado (pedido novo ou senha trocada) + + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + + UNIQUE KEY uq_password_resets_token (token_hash), + KEY idx_password_resets_merchant (merchant_id), + + CONSTRAINT fk_password_resets_merchant + FOREIGN KEY (merchant_id) REFERENCES merchants(id) + ON DELETE CASCADE + ON UPDATE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; diff --git a/database/schema.sql b/database/schema.sql index 70c3e0f..1cb33da 100644 --- a/database/schema.sql +++ b/database/schema.sql @@ -182,6 +182,29 @@ CREATE TABLE IF NOT EXISTS rate_limit_hits ( KEY idx_rate_limit_created (created_at) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; +-- ========================= +-- password_resets // links de redefinicao de senha (task 21) +-- ========================= +-- o token vai so no e-mail; aqui fica o hash sha-256 dele. vale 1 hora e uma vez so. +CREATE TABLE IF NOT EXISTS password_resets ( + id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY, + + merchant_id BIGINT UNSIGNED NOT NULL, + token_hash CHAR(64) NOT NULL, + expires_at TIMESTAMP NOT NULL, + used_at TIMESTAMP NULL, -- usado ou cancelado (pedido novo ou senha trocada) + + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + + UNIQUE KEY uq_password_resets_token (token_hash), + KEY idx_password_resets_merchant (merchant_id), + + CONSTRAINT fk_password_resets_merchant + FOREIGN KEY (merchant_id) REFERENCES merchants(id) + ON DELETE CASCADE + ON UPDATE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + -- ========================= -- schema_migrations // quais migrations de database/migrations ja rodaram neste banco -- ========================= @@ -200,4 +223,5 @@ INSERT IGNORE INTO schema_migrations (version) VALUES ('004_estorno'), ('005_regra_pontos'), ('006_points_log_extrato'), - ('007_customers_so_telefone'); + ('007_customers_so_telefone'), + ('008_password_resets'); diff --git a/docs/db/schema-explanation.md b/docs/db/schema-explanation.md index 9927d7e..1301922 100644 --- a/docs/db/schema-explanation.md +++ b/docs/db/schema-explanation.md @@ -85,3 +85,11 @@ O `schema.sql` já insere todas as migrations existentes: banco novo nasce sem p - **Lançar e resgatar** rodam em transação: o saldo do cartão e a linha do `points_log` são gravados juntos, ou nenhum dos dois é gravado. - **Resgate** trava a linha do cartão com `SELECT ... FOR UPDATE` antes de checar o saldo. Assim, dois resgates simultâneos não conseguem gastar os mesmos pontos. - **Cartão novo** é criado com `INSERT ... ON DUPLICATE KEY UPDATE id = LAST_INSERT_ID(id)`, que devolve o id existente sem risco de duplicar. + +### `password_resets` — links de nova senha (task 21) +| Coluna | Observação | +|---|---| +| `merchant_id` | Dono do link. `ON DELETE CASCADE`. | +| `token_hash` | `UNIQUE`. SHA-256 do token; o token em si só existe no e-mail. | +| `expires_at` | 1 hora depois do pedido. | +| `used_at` | Preenchido quando o link é usado, quando um pedido novo é feito ou quando a senha é trocada por ele: depois disso não vale mais. | diff --git a/phpstan-baseline.neon b/phpstan-baseline.neon index f88dfbc..91fdc17 100644 --- a/phpstan-baseline.neon +++ b/phpstan-baseline.neon @@ -1,5 +1,11 @@ parameters: ignoreErrors: + - + message: '#^Unreachable statement \- code above always terminates\.$#' + identifier: deadCode.unreachable + count: 2 + path: src/Controllers/MerchantController.php + - message: '#^Variable \$categories might not be defined\.$#' identifier: variable.undefined @@ -12,6 +18,24 @@ parameters: count: 1 path: views/auth/merchant/register.php + - + message: '#^Variable \$maxPassword might not be defined\.$#' + identifier: variable.undefined + count: 3 + path: views/auth/merchant/reset.php + + - + message: '#^Variable \$minPassword might not be defined\.$#' + identifier: variable.undefined + count: 3 + path: views/auth/merchant/reset.php + + - + message: '#^Variable \$token might not be defined\.$#' + identifier: variable.undefined + count: 1 + path: views/auth/merchant/reset.php + - message: '#^Variable \$card might not be defined\.$#' identifier: variable.undefined diff --git a/public/css/auth.css b/public/css/auth.css index fb0714a..09ead9f 100644 --- a/public/css/auth.css +++ b/public/css/auth.css @@ -100,3 +100,10 @@ .auth-container.auth-container-wide { max-width: 600px; } + +/* link "Esqueci minha senha" logo abaixo do botao de entrar (task 21) */ +.auth-forgot { + margin-top: 1rem; + text-align: center; + font-size: 0.9rem; +} diff --git a/public/index.php b/public/index.php index a5fe29f..c8348e9 100644 --- a/public/index.php +++ b/public/index.php @@ -102,6 +102,8 @@ match ($action ?? 'dashboard') { 'login' => $controller->renderLogin(), 'register' => $controller->renderRegister(), + 'forgot' => $controller->renderForgot(), + 'reset' => $controller->renderReset(), 'customer-new' => $controller->renderCustomerNew(), 'logout' => $controller->logout(), 'dashboard' => $controller->renderDashboard(), diff --git a/src/Controllers/MerchantController.php b/src/Controllers/MerchantController.php index 7be1792..67b1661 100644 --- a/src/Controllers/MerchantController.php +++ b/src/Controllers/MerchantController.php @@ -4,7 +4,10 @@ use App\Models\CustomerModel; use App\Models\LoyaltyCardModel; +use App\Mail\MailerFactory; +use App\Mail\Message; use App\Models\MerchantModel; +use App\Models\PasswordResetModel; use App\Models\PointsLogModel; use App\Models\RewardModel; use App\Support\Csrf; @@ -56,6 +59,10 @@ class MerchantController { // senha atual errada na troca de senha: mesmo limite do login, contado por conta private const MAX_PASSWORD_FAILURES = 5; private const PASSWORD_WINDOW_SECONDS = 900; + // pedidos de link de senha (task 21): por ip (quem testa varios e-mails) e por e-mail (nao lotar a caixa de ninguem) + private const MAX_RESET_REQUESTS_PER_IP = 10; + private const MAX_RESET_REQUESTS_PER_EMAIL = 3; + private const RESET_WINDOW_SECONDS = 3600; private $db; private $merchantModel; @@ -97,6 +104,99 @@ public function renderLogin() { View::render('auth/merchant/login'); } + // esqueci a senha (task 21): pede o e-mail e manda o link. a resposta e sempre a mesma, exista + // o e-mail ou nao: a tela nao revela quais e-mails sao lojas. + public function renderForgot() { + if ($_SERVER['REQUEST_METHOD'] === 'POST') { + $this->handleForgot(); + return; + } + + View::render('auth/merchant/forgot'); + } + + private function handleForgot(): never { + Csrf::verify(); + + $email = trim((string)($_POST['email'] ?? '')); + if (!filter_var($email, FILTER_VALIDATE_EMAIL)) { + redirect('merchant/forgot', ['error' => 'email_invalido']); + } + + $limiter = new RateLimiter($this->db); + $ip = RateLimiter::clientKey(); + if ($limiter->tooMany('password_reset_ip', $ip, self::MAX_RESET_REQUESTS_PER_IP, self::RESET_WINDOW_SECONDS) + || $limiter->tooMany('password_reset_email', $email, self::MAX_RESET_REQUESTS_PER_EMAIL, self::RESET_WINDOW_SECONDS)) { + redirect('merchant/forgot', ['error' => 'muitos_pedidos']); + } + $limiter->hit('password_reset_ip', $ip); + $limiter->hit('password_reset_email', $email); + + // conta desativada nao recebe link (nao conseguiria entrar mesmo) + $merchant = $this->merchantModel->findByEmail($email); + if ($merchant && $merchant['status'] === 'active') { + $token = (new PasswordResetModel($this->db))->create((int)$merchant['id']); + $link = $this->publicBaseUrl() . url('merchant/reset', ['token' => $token]); + MailerFactory::fromEnv()->send(new Message( + $email, + 'FidelityX: criar uma nova senha', + "Olá, {$merchant['owner_name']}.\n\n" + . "Recebemos um pedido para criar uma nova senha no painel da loja {$merchant['store_name']}.\n" + . "Abra o link abaixo em até 1 hora (ele só funciona uma vez):\n\n$link\n\n" + . "Se não foi você, ignore este e-mail: a senha atual continua valendo." + )); + } + + redirect('merchant/forgot', ['success' => 'reset_enviado']); + } + + // link do e-mail: escolhe a senha nova. o token vem na url (GET) e volta num campo escondido (POST). + public function renderReset() { + if ($_SERVER['REQUEST_METHOD'] === 'POST') { + $this->handleReset(); + return; + } + + $token = (string)($_GET['token'] ?? ''); + if (!(new PasswordResetModel($this->db))->isValid($token)) { + redirect('merchant/forgot', ['error' => 'link_invalido']); + } + + View::render('auth/merchant/reset', [ + 'token' => $token, + 'minPassword' => PasswordPolicy::MIN_BYTES, + 'maxPassword' => PasswordPolicy::MAX_BYTES, + ]); + } + + private function handleReset(): never { + Csrf::verify(); + + $token = (string)($_POST['token'] ?? ''); + $new = (string)($_POST['new_password'] ?? ''); + $confirm = (string)($_POST['new_password_confirm'] ?? ''); + $resets = new PasswordResetModel($this->db); + + if (!$resets->isValid($token)) { + redirect('merchant/forgot', ['error' => 'link_invalido']); + } + + $passwordProblem = PasswordPolicy::problem($new); + if ($passwordProblem !== null) { + redirect('merchant/reset', ['token' => $token, 'error' => $passwordProblem]); + } + if (!hash_equals($new, $confirm)) { + redirect('merchant/reset', ['token' => $token, 'error' => 'senhas_diferentes']); + } + + // a senha nova derruba as sessoes abertas com a antiga (password_sig, ver authGuard) + if ($resets->resetPassword($token, password_hash($new, PASSWORD_BCRYPT)) === null) { + redirect('merchant/forgot', ['error' => 'link_invalido']); + } + + redirect('merchant/login', ['success' => 'senha_redefinida']); + } + // RENDERS (privadas, exigem login) ----------------------------------- // home do lojista: um campo de telefone que decide o destino. diff --git a/src/Models/PasswordResetModel.php b/src/Models/PasswordResetModel.php new file mode 100644 index 0000000..b71b10f --- /dev/null +++ b/src/Models/PasswordResetModel.php @@ -0,0 +1,87 @@ +db = $db; + } + + public static function hash(string $token): string { + return hash('sha256', $token); + } + + // cria um link novo e devolve o token (o que vai no e-mail). pedidos anteriores ainda abertos + // deixam de valer: so o ultimo link enviado funciona. + public function create(int $merchantId): string { + $token = bin2hex(random_bytes(32)); + + $this->db->beginTransaction(); + try { + $this->db->prepare('UPDATE password_resets SET used_at = CURRENT_TIMESTAMP WHERE merchant_id = :id AND used_at IS NULL') + ->execute([':id' => $merchantId]); + $this->db->prepare( + 'INSERT INTO password_resets (merchant_id, token_hash, expires_at) + VALUES (:merchant_id, :token_hash, NOW() + INTERVAL ' . self::TTL_SECONDS . ' SECOND)' + )->execute([':merchant_id' => $merchantId, ':token_hash' => self::hash($token)]); + $this->db->commit(); + } catch (\Throwable $e) { + $this->db->rollBack(); + throw $e; + } + + return $token; + } + + // o link ainda vale (existe, nao foi usado, nao venceu e a conta esta ativa) + public function isValid(string $token): bool { + return $token !== '' && $this->findValid(self::hash($token), false) !== false; + } + + // troca a senha pelo link, tudo ou nada: o link (e qualquer outro aberto da conta) deixa de valer. + // FOR UPDATE: dois envios do mesmo link ao mesmo tempo nao trocam a senha duas vezes. + // devolve o id do lojista ou null se o link nao vale. + public function resetPassword(string $token, string $passwordHash): ?int { + if ($token === '') { + return null; + } + + $this->db->beginTransaction(); + try { + $merchantId = $this->findValid(self::hash($token), true); + if ($merchantId === false) { + $this->db->rollBack(); + return null; + } + + $this->db->prepare('UPDATE merchants SET password_hash = :hash WHERE id = :id') + ->execute([':hash' => $passwordHash, ':id' => $merchantId]); + $this->db->prepare('UPDATE password_resets SET used_at = CURRENT_TIMESTAMP WHERE merchant_id = :id AND used_at IS NULL') + ->execute([':id' => $merchantId]); + + $this->db->commit(); + return (int)$merchantId; + } catch (\Throwable $e) { + $this->db->rollBack(); + throw $e; + } + } + + // id do lojista dono do link valido, ou false + private function findValid(string $tokenHash, bool $lock) { + $stmt = $this->db->prepare( + "SELECT pr.merchant_id FROM password_resets pr + JOIN merchants m ON m.id = pr.merchant_id + WHERE pr.token_hash = :token_hash AND pr.used_at IS NULL AND pr.expires_at > NOW() + AND m.status = 'active'" . ($lock ? ' FOR UPDATE' : '') + ); + $stmt->execute([':token_hash' => $tokenHash]); + return $stmt->fetchColumn(); + } +} diff --git a/tests/Feature/PasswordResetTest.php b/tests/Feature/PasswordResetTest.php new file mode 100644 index 0000000..f3702a5 --- /dev/null +++ b/tests/Feature/PasswordResetTest.php @@ -0,0 +1,147 @@ +post('merchant/forgot', ['email' => $email])[1]; + } + + // token do link do ultimo e-mail enviado + private function tokenFromMail(): string { + $mails = $this->sentMails(); + $this->assertNotEmpty($mails, 'nenhum e-mail enviado'); + $this->assertMatchesRegularExpression('#http://127\.0\.0\.1:\d+/index\.php\?url=merchant%2Freset&token=([a-f0-9]{64})#', end($mails)['body']); + preg_match('#token=([a-f0-9]{64})#', end($mails)['body'], $m); + return $m[1]; + } + + private function reset(string $token, string $password, ?string $confirm = null): ?string { + return $this->post( + 'merchant/reset', + ['token' => $token, 'new_password' => $password, 'new_password_confirm' => $confirm ?? $password], + true, + 'merchant/reset&token=' . $token + )[1]; + } + + public function testLoginTemOLinkDeEsqueciASenha(): void { + $this->get('merchant/login'); + $this->assertStringContainsString('merchant%2Fforgot', $this->lastBody); + $this->assertSame(200, $this->get('merchant/forgot')[0]); + } + + public function testFluxoCompletoTrocaASenhaEOLinkSoValeUmaVez(): void { + $this->createMerchant('loja@teste.test'); + + $this->assertSame('merchant/forgot&success=reset_enviado', $this->requestLink('loja@teste.test')); + $mail = $this->sentMails()[0]; + $this->assertSame('loja@teste.test', $mail['to']); + $this->assertStringContainsString('1 hora', $mail['body']); + $token = $this->tokenFromMail(); + + // o banco guarda so o hash do token + $this->assertSame(0, (int)$this->scalar('SELECT COUNT(*) FROM password_resets WHERE token_hash = ?', [$token])); + $this->assertSame(1, (int)$this->scalar('SELECT COUNT(*) FROM password_resets WHERE token_hash = ?', [hash('sha256', $token)])); + + [$status] = $this->get('merchant/reset&token=' . $token); + $this->assertSame(200, $status); + $this->assertStringContainsString('name="token" value="' . $token . '"', $this->lastBody); + + $this->assertSame('merchant/reset&token=' . $token . '&error=senha_curta', $this->reset($token, '1234567')); + $this->assertSame('merchant/reset&token=' . $token . '&error=senhas_diferentes', $this->reset($token, 'nova-senha-1', 'outra-senha-1')); + $this->assertSame('merchant/login&success=senha_redefinida', $this->reset($token, 'nova-senha-1')); + + $this->newSession(); + [, $location] = $this->post('merchant/login', ['email' => 'loja@teste.test', 'password' => 'teste123']); + $this->assertSame('merchant/login&error=credenciais_invalidas', $location); + $this->loginAs('loja@teste.test', 'nova-senha-1'); + + // de novo: o link ja foi usado + $this->newSession(); + [, $location] = $this->get('merchant/reset&token=' . $token); + $this->assertSame('merchant/forgot&error=link_invalido', $location); + } + + public function testEmailQueNaoELojaRespondeIgualENaoMandaNada(): void { + $this->assertSame('merchant/forgot&success=reset_enviado', $this->requestLink('ninguem@teste.test')); + $this->assertSame([], $this->sentMails()); + $this->assertSame('merchant/forgot&error=email_invalido', $this->requestLink('nao-e-email')); + } + + public function testContaDesativadaNaoRecebeLink(): void { + $id = $this->createMerchant('loja@teste.test'); + $this->db->exec("UPDATE merchants SET status = 'inactive' WHERE id = $id"); + + $this->assertSame('merchant/forgot&success=reset_enviado', $this->requestLink('loja@teste.test')); + $this->assertSame([], $this->sentMails()); + } + + public function testLinkVencidoOuInventadoNaoVale(): void { + $this->createMerchant('loja@teste.test'); + $this->requestLink('loja@teste.test'); + $token = $this->tokenFromMail(); + + foreach (['', 'abc', str_repeat('a', 64)] as $fake) { + [, $location] = $this->get('merchant/reset&token=' . $fake); + $this->assertSame('merchant/forgot&error=link_invalido', $location, $fake); + } + + $this->db->exec('UPDATE password_resets SET expires_at = NOW() - INTERVAL 1 SECOND'); + [, $location] = $this->get('merchant/reset&token=' . $token); + $this->assertSame('merchant/forgot&error=link_invalido', $location); + + // POST direto com o token vencido tambem nao troca + $this->get('merchant/forgot'); + [, $location] = $this->post('merchant/reset', ['token' => $token, 'new_password' => 'nova-senha-1', 'new_password_confirm' => 'nova-senha-1'], true, 'merchant/forgot'); + $this->assertSame('merchant/forgot&error=link_invalido', $location); + $this->loginAs('loja@teste.test'); + } + + public function testSoOUltimoLinkPedidoVale(): void { + $this->createMerchant('loja@teste.test'); + $this->requestLink('loja@teste.test'); + $first = $this->tokenFromMail(); + $this->requestLink('loja@teste.test'); + $second = $this->tokenFromMail(); + + $this->assertNotSame($first, $second); + $this->assertSame('merchant/forgot&error=link_invalido', $this->get('merchant/reset&token=' . $first)[1]); + $this->assertSame(200, $this->get('merchant/reset&token=' . $second)[0]); + } + + // a senha nova derruba quem estava logado com a antiga + public function testRedefinirDerrubaAsSessoesAbertas(): void { + $this->createMerchant('loja@teste.test'); + $this->loginAs('loja@teste.test'); + $logged = $this->cookie('PHPSESSID'); + + $this->newSession(); + $this->requestLink('loja@teste.test'); + $this->reset($this->tokenFromMail(), 'nova-senha-1'); + + $this->newSession(); + $this->setCookie('PHPSESSID', (string)$logged); + [, $location] = $this->get('merchant/dashboard'); + $this->assertSame('merchant/login&error=sessao_expirada', $location); + } + + public function testPedidosDemaisParaOMesmoEmailSaoBarrados(): void { + $this->createMerchant('loja@teste.test'); + for ($i = 0; $i < 3; $i++) { + $this->assertSame('merchant/forgot&success=reset_enviado', $this->requestLink('loja@teste.test')); + } + $this->assertSame('merchant/forgot&error=muitos_pedidos', $this->requestLink('loja@teste.test')); + $this->assertCount(3, $this->sentMails()); + } + + public function testPedidoExigeCsrf(): void { + $this->createMerchant('loja@teste.test'); + [$status] = $this->post('merchant/forgot', ['email' => 'loja@teste.test'], false); + $this->assertSame(403, $status); + $this->assertSame([], $this->sentMails()); + } +} diff --git a/tests/Support/TestDatabase.php b/tests/Support/TestDatabase.php index 7f45796..c944d35 100644 --- a/tests/Support/TestDatabase.php +++ b/tests/Support/TestDatabase.php @@ -7,7 +7,7 @@ // banco de teste: recriado do schema.sql no inicio da rodada e esvaziado antes de cada teste final class TestDatabase { // ordem respeita as chaves estrangeiras (filhos antes dos pais) - private const TABLES = ['points_log', 'rewards', 'loyalty_cards', 'customers', 'merchants', 'rate_limit_hits']; + private const TABLES = ['points_log', 'rewards', 'loyalty_cards', 'customers', 'password_resets', 'merchants', 'rate_limit_hits']; public static function name(): string { return $_ENV['DB_NAME']; diff --git a/views/auth/merchant/forgot.php b/views/auth/merchant/forgot.php new file mode 100644 index 0000000..28e67b7 --- /dev/null +++ b/views/auth/merchant/forgot.php @@ -0,0 +1,43 @@ + + + + + + + Esqueci minha senha | FidelityX + + + + + + + +
+
+ +

Criar uma nova senha

+
+ + + +

Informe o e-mail de acesso da loja. Enviamos um link para criar uma nova senha, válido por 1 hora.

+ +
+ + +
+ + +
+ + + + + +
+ + + + diff --git a/views/auth/merchant/login.php b/views/auth/merchant/login.php index f255110..2b73ee5 100644 --- a/views/auth/merchant/login.php +++ b/views/auth/merchant/login.php @@ -36,6 +36,8 @@ +

Esqueci minha senha

+

Ainda não é parceiro? Cadastre sua loja

É cliente? Consulte seus pontos

diff --git a/views/auth/merchant/reset.php b/views/auth/merchant/reset.php new file mode 100644 index 0000000..7fcd7c8 --- /dev/null +++ b/views/auth/merchant/reset.php @@ -0,0 +1,44 @@ + + + + + + + Nova senha | FidelityX + + + + + + + +
+
+ +

Escolha a nova senha

+
+ + + +
+ + + +
+ + +
+ +
+ + +
+

De a caracteres. Quem estiver conectado com a senha antiga precisa entrar de novo.

+ + + +
+ + + + diff --git a/views/partials/flash.php b/views/partials/flash.php index 1dbe168..353468d 100644 --- a/views/partials/flash.php +++ b/views/partials/flash.php @@ -40,6 +40,8 @@ 'estorno_expirado' => 'Só dá para estornar lançamentos e resgates das últimas 24 horas.', 'estorno_sem_saldo' => 'Não dá para estornar: o cliente já usou parte desses pontos.', 'senha_atual_incorreta' => 'A senha atual não confere.', + 'link_invalido' => 'Este link de nova senha é inválido, já foi usado ou venceu. Peça outro.', + 'muitos_pedidos' => 'Muitos pedidos seguidos. Aguarde um pouco e tente de novo.', 'muitas_tentativas' => 'Muitas tentativas com a senha atual errada. Aguarde alguns minutos e tente de novo.', ], 'success' => [ @@ -62,6 +64,8 @@ 'regra_salva' => 'Regra de pontos salva.', 'regra_removida' => 'Regra de pontos removida.', 'perfil_atualizado' => 'Dados da loja atualizados.', + 'reset_enviado' => 'Se o e-mail for de uma loja cadastrada, enviamos um link para criar uma nova senha. Ele vale por 1 hora.', + 'senha_redefinida' => 'Senha nova criada. Entre com ela.', 'senha_alterada' => 'Senha alterada. Os outros aparelhos conectados precisam entrar de novo.', ], ]; From 66b36c6a2453e2dd361baead8eced289b3e07798 Mon Sep 17 00:00:00 2001 From: "Emanuel G. Sousa" Date: Tue, 6 Oct 2026 17:37:43 -0300 Subject: [PATCH 12/15] feat(conta): confirmacao do e-mail do lojista Cadastro manda um link (24 h, uso unico, so o hash no banco); enquanto nao confirma, o lojista entra mas so ve a tela de confirmar, com reenvio. Contas existentes contam como confirmadas (migration 009). Os links por e-mail passam a herdar de MerchantTokenModel. Task 50. Co-Authored-By: Claude Opus 5.5 --- CLAUDE.md | 4 +- .../migrations/009_email_verifications.sql | 32 +++++ database/schema.sql | 27 ++++- docs/db/schema-explanation.md | 4 + phpstan-baseline.neon | 6 + public/index.php | 2 + src/Controllers/MerchantController.php | 68 ++++++++++- src/Models/EmailVerificationModel.php | 17 +++ src/Models/MerchantModel.php | 2 +- src/Models/MerchantTokenModel.php | 91 ++++++++++++++ src/Models/PasswordResetModel.php | 82 +------------ tests/Feature/BalcaoFlowTest.php | 1 + tests/Feature/EmailVerificationTest.php | 113 ++++++++++++++++++ tests/Feature/RegisterTest.php | 1 + tests/Support/DatabaseTestCase.php | 4 +- tests/Support/HttpTestCase.php | 10 ++ tests/Support/TestDatabase.php | 2 +- views/merchant/confirm-email.php | 16 +++ views/partials/flash.php | 5 +- 19 files changed, 399 insertions(+), 88 deletions(-) create mode 100644 database/migrations/009_email_verifications.sql create mode 100644 src/Models/EmailVerificationModel.php create mode 100644 src/Models/MerchantTokenModel.php create mode 100644 tests/Feature/EmailVerificationTest.php create mode 100644 views/merchant/confirm-email.php diff --git a/CLAUDE.md b/CLAUDE.md index 2e7895a..09a4079 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -48,13 +48,13 @@ 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) 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. -**Esqueci a senha** (task 21) — `merchant/forgot` manda por e-mail um link `merchant/reset&token=...` (`PasswordResetModel`: 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`). +**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. diff --git a/database/migrations/009_email_verifications.sql b/database/migrations/009_email_verifications.sql new file mode 100644 index 0000000..50ccc90 --- /dev/null +++ b/database/migrations/009_email_verifications.sql @@ -0,0 +1,32 @@ +-- fidelityx - migration 009 (confirmacao do e-mail do lojista, task 50) +-- para bancos criados antes desta versao do schema.sql. rodar uma unica vez: +-- php bin/migrate.php +-- (ou, a mao: mysql -u root -p fidelityx < database/migrations/009_email_verifications.sql) + +USE fidelityx; + +-- conta nova so usa o painel depois de confirmar o e-mail (NULL = ainda nao confirmou) +ALTER TABLE merchants ADD COLUMN email_verified_at TIMESTAMP NULL AFTER email; + +-- contas que ja existiam contam como confirmadas: nao da pra travar quem ja usa o sistema +UPDATE merchants SET email_verified_at = created_at WHERE email_verified_at IS NULL; + +-- link de confirmacao: como o de nova senha, so o hash do token fica aqui. vale 24 horas e uma vez. +CREATE TABLE IF NOT EXISTS email_verifications ( + id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY, + + merchant_id BIGINT UNSIGNED NOT NULL, + token_hash CHAR(64) NOT NULL, + expires_at TIMESTAMP NOT NULL, + used_at TIMESTAMP NULL, -- usado ou cancelado (reenvio) + + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + + UNIQUE KEY uq_email_verifications_token (token_hash), + KEY idx_email_verifications_merchant (merchant_id), + + CONSTRAINT fk_email_verifications_merchant + FOREIGN KEY (merchant_id) REFERENCES merchants(id) + ON DELETE CASCADE + ON UPDATE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; diff --git a/database/schema.sql b/database/schema.sql index 1cb33da..3e7dfeb 100644 --- a/database/schema.sql +++ b/database/schema.sql @@ -19,6 +19,7 @@ CREATE TABLE IF NOT EXISTS merchants ( owner_name VARCHAR(255) NOT NULL, store_name VARCHAR(255) NOT NULL, email VARCHAR(255) NOT NULL, + email_verified_at TIMESTAMP NULL, -- conta nova so usa o painel depois de confirmar o e-mail (task 50) phone VARCHAR(30) NOT NULL, category VARCHAR(30) NOT NULL, password_hash VARCHAR(255) NOT NULL, @@ -205,6 +206,29 @@ CREATE TABLE IF NOT EXISTS password_resets ( ON UPDATE CASCADE ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; +-- ========================= +-- email_verifications // links de confirmacao do e-mail do lojista (task 50) +-- ========================= +-- como o de nova senha: so o hash do token fica aqui. vale 24 horas e uma vez. +CREATE TABLE IF NOT EXISTS email_verifications ( + id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY, + + merchant_id BIGINT UNSIGNED NOT NULL, + token_hash CHAR(64) NOT NULL, + expires_at TIMESTAMP NOT NULL, + used_at TIMESTAMP NULL, -- usado ou cancelado (reenvio) + + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + + UNIQUE KEY uq_email_verifications_token (token_hash), + KEY idx_email_verifications_merchant (merchant_id), + + CONSTRAINT fk_email_verifications_merchant + FOREIGN KEY (merchant_id) REFERENCES merchants(id) + ON DELETE CASCADE + ON UPDATE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + -- ========================= -- schema_migrations // quais migrations de database/migrations ja rodaram neste banco -- ========================= @@ -224,4 +248,5 @@ INSERT IGNORE INTO schema_migrations (version) VALUES ('005_regra_pontos'), ('006_points_log_extrato'), ('007_customers_so_telefone'), - ('008_password_resets'); + ('008_password_resets'), + ('009_email_verifications'); diff --git a/docs/db/schema-explanation.md b/docs/db/schema-explanation.md index 1301922..6814e92 100644 --- a/docs/db/schema-explanation.md +++ b/docs/db/schema-explanation.md @@ -29,6 +29,7 @@ merchants 1───N loyalty_cards N───1 customers | `password_hash` | BCRYPT (`password_hash`). | | `plan` | `free`/`pro`. Ainda não é usado pelo código (pós-MVP). | | `status` | Padrão `active`. Contas `inactive` são barradas no login. | +| `email_verified_at` | Quando o e-mail foi confirmado pelo link (task 50). `NULL` = conta nova ainda sem confirmar: entra, mas só vê a tela de confirmação. A migration 009 marcou as contas antigas como confirmadas. | | `category`, `state` | Validados contra as listas de `MerchantController::CATEGORIES` e `STATES`. | ### `customers` — clientes @@ -93,3 +94,6 @@ O `schema.sql` já insere todas as migrations existentes: banco novo nasce sem p | `token_hash` | `UNIQUE`. SHA-256 do token; o token em si só existe no e-mail. | | `expires_at` | 1 hora depois do pedido. | | `used_at` | Preenchido quando o link é usado, quando um pedido novo é feito ou quando a senha é trocada por ele: depois disso não vale mais. | + +### `email_verifications` — links de confirmação do e-mail (task 50) +Mesma forma da `password_resets` (`merchant_id`, `token_hash` com `UNIQUE`, `expires_at`, `used_at`), com validade de 24 horas. Reenviar o link cancela o anterior. diff --git a/phpstan-baseline.neon b/phpstan-baseline.neon index 91fdc17..b76775b 100644 --- a/phpstan-baseline.neon +++ b/phpstan-baseline.neon @@ -180,6 +180,12 @@ parameters: count: 1 path: views/errors/default.php + - + message: '#^Variable \$email might not be defined\.$#' + identifier: variable.undefined + count: 1 + path: views/merchant/confirm-email.php + - message: '#^Variable \$phone might not be defined\.$#' identifier: variable.undefined diff --git a/public/index.php b/public/index.php index c8348e9..ff1c718 100644 --- a/public/index.php +++ b/public/index.php @@ -104,6 +104,8 @@ 'register' => $controller->renderRegister(), 'forgot' => $controller->renderForgot(), 'reset' => $controller->renderReset(), + 'confirm-email' => $controller->renderConfirmEmail(), + 'verify-email' => $controller->renderVerifyEmail(), 'customer-new' => $controller->renderCustomerNew(), 'logout' => $controller->logout(), 'dashboard' => $controller->renderDashboard(), diff --git a/src/Controllers/MerchantController.php b/src/Controllers/MerchantController.php index 67b1661..d17f64b 100644 --- a/src/Controllers/MerchantController.php +++ b/src/Controllers/MerchantController.php @@ -3,6 +3,7 @@ namespace App\Controllers; use App\Models\CustomerModel; +use App\Models\EmailVerificationModel; use App\Models\LoyaltyCardModel; use App\Mail\MailerFactory; use App\Mail\Message; @@ -63,6 +64,8 @@ class MerchantController { private const MAX_RESET_REQUESTS_PER_IP = 10; private const MAX_RESET_REQUESTS_PER_EMAIL = 3; private const RESET_WINDOW_SECONDS = 3600; + // reenvio do link de confirmacao do e-mail (task 50), por conta + private const MAX_VERIFY_RESENDS = 3; private $db; private $merchantModel; @@ -650,9 +653,7 @@ public function handleRegister(){ ]; $this->merchantModel->create($data); - - // se deu certo, redireciona usuario para o login - redirect('merchant/login', ['success' => 'cadastrado']); + $merchantId = (int)$this->db->lastInsertId(); } catch (\PDOException $e) { $sqlState = $e->errorInfo[0] ?? null; $driverCode = (int)($e->errorInfo[1] ?? 0); @@ -688,6 +689,59 @@ public function handleRegister(){ redirect('merchant/register', ['error' => 'erro_servidor']); } + // conta nova so usa o painel depois de confirmar o e-mail (task 50) + $this->sendEmailVerification($merchantId, $email, $owner_name); + + // se deu certo, redireciona usuario para o login + redirect('merchant/login', ['success' => 'cadastrado']); + } + + // manda (ou reenvia) o link de confirmacao do e-mail; o anterior deixa de valer + private function sendEmailVerification(int $merchantId, string $email, string $ownerName): void { + $token = (new EmailVerificationModel($this->db))->create($merchantId); + $link = $this->publicBaseUrl() . url('merchant/verify-email', ['token' => $token]); + MailerFactory::fromEnv()->send(new Message( + $email, + 'FidelityX: confirme seu e-mail', + "Olá, $ownerName.\n\n" + . "Para começar a usar o painel do FidelityX, confirme que este e-mail é seu abrindo o link abaixo\n" + . "(ele vale por 24 horas):\n\n$link\n\n" + . "Se você não criou uma conta no FidelityX, ignore este e-mail." + )); + } + + // tela de quem entrou mas ainda nao confirmou o e-mail: explica e reenvia o link (task 50) + public function renderConfirmEmail() { + $merchantId = $this->authGuard(true); + if ($this->merchant['email_verified_at'] !== null) { + redirect('merchant/dashboard'); + } + + if ($_SERVER['REQUEST_METHOD'] === 'POST') { + Csrf::verify(); + $limiter = new RateLimiter($this->db); + $key = 'merchant:' . $merchantId; + if ($limiter->tooMany('email_verify_resend', $key, self::MAX_VERIFY_RESENDS, self::RESET_WINDOW_SECONDS)) { + redirect('merchant/confirm-email', ['error' => 'muitos_pedidos']); + } + $limiter->hit('email_verify_resend', $key); + $this->sendEmailVerification($merchantId, $this->merchant['email'], $this->merchant['owner_name']); + redirect('merchant/confirm-email', ['success' => 'confirmacao_reenviada']); + } + + View::render('merchant/confirm-email', ['email' => $this->merchant['email']]); + } + + // link do e-mail de confirmacao (nao exige login: pode ser aberto no celular, fora da sessao do painel) + public function renderVerifyEmail() { + $verified = (new EmailVerificationModel($this->db))->verify((string)($_GET['token'] ?? '')); + $logged = isset($_SESSION['merchant_id']); + + if ($verified === null) { + redirect($logged ? 'merchant/confirm-email' : 'merchant/login', ['error' => 'confirmacao_invalida']); + } + + redirect($logged ? 'merchant/dashboard' : 'merchant/login', ['success' => 'email_confirmado']); } public function handleLogin(){ @@ -1073,7 +1127,8 @@ private function readRewardFields(): ?array { // o id SEMPRE vem da sessao, nunca do formulario. // a cada requisicao: sessao parada demais expira, e a conta e conferida no banco // (lojista desativado perde o acesso na hora, nao so no proximo login). - private function authGuard(): int { + // $allowUnverified: so a tela de confirmar o e-mail aceita conta que ainda nao confirmou (task 50) + private function authGuard(bool $allowUnverified = false): int { if(!isset($_SESSION['merchant_id'])){ redirect('merchant/login', ['error' => 'sessao_expirada']); } @@ -1098,6 +1153,11 @@ private function authGuard(): int { // a loja da requisicao fica a mao (ex.: regra de pontos), sem consultar o banco de novo $this->merchant = $merchant; + // conta nova que ainda nao confirmou o e-mail so ve a tela de confirmar + if (!$allowUnverified && $merchant['email_verified_at'] === null) { + redirect('merchant/confirm-email'); + } + // nome da loja sempre atualizado no menu (pode ter mudado desde o login) $_SESSION['last_seen'] = time(); $_SESSION['merchant_name'] = $merchant['owner_name']; diff --git a/src/Models/EmailVerificationModel.php b/src/Models/EmailVerificationModel.php new file mode 100644 index 0000000..b5a6d5a --- /dev/null +++ b/src/Models/EmailVerificationModel.php @@ -0,0 +1,17 @@ +consume($token, function (int $merchantId) { + $this->db->prepare('UPDATE merchants SET email_verified_at = CURRENT_TIMESTAMP WHERE id = :id AND email_verified_at IS NULL') + ->execute([':id' => $merchantId]); + }); + } +} diff --git a/src/Models/MerchantModel.php b/src/Models/MerchantModel.php index bb530a7..bc95c63 100644 --- a/src/Models/MerchantModel.php +++ b/src/Models/MerchantModel.php @@ -42,7 +42,7 @@ public function findByEmail($email){ // dados que o authGuard confere a cada requisicao (o hash da senha serve pra derrubar // as sessoes antigas quando a senha e trocada, ver SessionGuard::passwordSignature) public function findById($merchantId) { - $stmt = $this->db->prepare('SELECT id, owner_name, store_name, status, points_rule_cents, password_hash FROM merchants WHERE id = :id'); + $stmt = $this->db->prepare('SELECT id, owner_name, store_name, email, email_verified_at, status, points_rule_cents, password_hash FROM merchants WHERE id = :id'); $stmt->execute([':id' => $merchantId]); return $stmt->fetch(\PDO::FETCH_ASSOC); diff --git a/src/Models/MerchantTokenModel.php b/src/Models/MerchantTokenModel.php new file mode 100644 index 0000000..087d960 --- /dev/null +++ b/src/Models/MerchantTokenModel.php @@ -0,0 +1,91 @@ +db = $db; + } + + public static function hash(string $token): string { + return hash('sha256', $token); + } + + // cria um link novo e devolve o token (o que vai no e-mail). os anteriores ainda abertos deixam de valer. + public function create(int $merchantId): string { + $token = bin2hex(random_bytes(32)); + + $this->db->beginTransaction(); + try { + $this->cancelOpen($merchantId); + $this->db->prepare( + 'INSERT INTO ' . static::TABLE . ' (merchant_id, token_hash, expires_at) + VALUES (:merchant_id, :token_hash, NOW() + INTERVAL ' . static::TTL_SECONDS . ' SECOND)' + )->execute([':merchant_id' => $merchantId, ':token_hash' => self::hash($token)]); + $this->db->commit(); + } catch (\Throwable $e) { + $this->db->rollBack(); + throw $e; + } + + return $token; + } + + // o link ainda vale (existe, nao foi usado, nao venceu e a conta esta ativa) + public function isValid(string $token): bool { + return $token !== '' && $this->findValid(self::hash($token), false) !== false; + } + + // usa o link, tudo ou nada: $apply recebe o id do lojista e faz a mudanca; depois o link (e qualquer + // outro aberto da conta) deixa de valer. FOR UPDATE: dois envios do mesmo link ao mesmo tempo nao + // aplicam duas vezes. devolve o id do lojista ou null se o link nao vale. + protected function consume(string $token, callable $apply): ?int { + if ($token === '') { + return null; + } + + $this->db->beginTransaction(); + try { + $merchantId = $this->findValid(self::hash($token), true); + if ($merchantId === false) { + $this->db->rollBack(); + return null; + } + + $apply((int)$merchantId); + $this->cancelOpen((int)$merchantId); + + $this->db->commit(); + return (int)$merchantId; + } catch (\Throwable $e) { + $this->db->rollBack(); + throw $e; + } + } + + private function cancelOpen(int $merchantId): void { + $this->db->prepare('UPDATE ' . static::TABLE . ' SET used_at = CURRENT_TIMESTAMP WHERE merchant_id = :id AND used_at IS NULL') + ->execute([':id' => $merchantId]); + } + + // id do lojista dono do link valido, ou false + private function findValid(string $tokenHash, bool $lock) { + $stmt = $this->db->prepare( + 'SELECT t.merchant_id FROM ' . static::TABLE . " t + JOIN merchants m ON m.id = t.merchant_id + WHERE t.token_hash = :token_hash AND t.used_at IS NULL AND t.expires_at > NOW() + AND m.status = 'active'" . ($lock ? ' FOR UPDATE' : '') + ); + $stmt->execute([':token_hash' => $tokenHash]); + return $stmt->fetchColumn(); + } +} diff --git a/src/Models/PasswordResetModel.php b/src/Models/PasswordResetModel.php index b71b10f..108578a 100644 --- a/src/Models/PasswordResetModel.php +++ b/src/Models/PasswordResetModel.php @@ -2,86 +2,16 @@ namespace App\Models; -// links de redefinicao de senha (task 21). o token so existe no e-mail; o banco guarda o hash sha-256, -// entao quem ler o banco (ou um backup) nao consegue usar um link. -class PasswordResetModel { +// links de nova senha (task 21): valem 1 hora (ver MerchantTokenModel) +class PasswordResetModel extends MerchantTokenModel { + protected const TABLE = 'password_resets'; public const TTL_SECONDS = 3600; - private $db; - - function __construct($db) { - $this->db = $db; - } - - public static function hash(string $token): string { - return hash('sha256', $token); - } - - // cria um link novo e devolve o token (o que vai no e-mail). pedidos anteriores ainda abertos - // deixam de valer: so o ultimo link enviado funciona. - public function create(int $merchantId): string { - $token = bin2hex(random_bytes(32)); - - $this->db->beginTransaction(); - try { - $this->db->prepare('UPDATE password_resets SET used_at = CURRENT_TIMESTAMP WHERE merchant_id = :id AND used_at IS NULL') - ->execute([':id' => $merchantId]); - $this->db->prepare( - 'INSERT INTO password_resets (merchant_id, token_hash, expires_at) - VALUES (:merchant_id, :token_hash, NOW() + INTERVAL ' . self::TTL_SECONDS . ' SECOND)' - )->execute([':merchant_id' => $merchantId, ':token_hash' => self::hash($token)]); - $this->db->commit(); - } catch (\Throwable $e) { - $this->db->rollBack(); - throw $e; - } - - return $token; - } - - // o link ainda vale (existe, nao foi usado, nao venceu e a conta esta ativa) - public function isValid(string $token): bool { - return $token !== '' && $this->findValid(self::hash($token), false) !== false; - } - - // troca a senha pelo link, tudo ou nada: o link (e qualquer outro aberto da conta) deixa de valer. - // FOR UPDATE: dois envios do mesmo link ao mesmo tempo nao trocam a senha duas vezes. - // devolve o id do lojista ou null se o link nao vale. + // troca a senha pelo link; devolve o id do lojista ou null se o link nao vale public function resetPassword(string $token, string $passwordHash): ?int { - if ($token === '') { - return null; - } - - $this->db->beginTransaction(); - try { - $merchantId = $this->findValid(self::hash($token), true); - if ($merchantId === false) { - $this->db->rollBack(); - return null; - } - + return $this->consume($token, function (int $merchantId) use ($passwordHash) { $this->db->prepare('UPDATE merchants SET password_hash = :hash WHERE id = :id') ->execute([':hash' => $passwordHash, ':id' => $merchantId]); - $this->db->prepare('UPDATE password_resets SET used_at = CURRENT_TIMESTAMP WHERE merchant_id = :id AND used_at IS NULL') - ->execute([':id' => $merchantId]); - - $this->db->commit(); - return (int)$merchantId; - } catch (\Throwable $e) { - $this->db->rollBack(); - throw $e; - } - } - - // id do lojista dono do link valido, ou false - private function findValid(string $tokenHash, bool $lock) { - $stmt = $this->db->prepare( - "SELECT pr.merchant_id FROM password_resets pr - JOIN merchants m ON m.id = pr.merchant_id - WHERE pr.token_hash = :token_hash AND pr.used_at IS NULL AND pr.expires_at > NOW() - AND m.status = 'active'" . ($lock ? ' FOR UPDATE' : '') - ); - $stmt->execute([':token_hash' => $tokenHash]); - return $stmt->fetchColumn(); + }); } } diff --git a/tests/Feature/BalcaoFlowTest.php b/tests/Feature/BalcaoFlowTest.php index 7da6958..d6c8e03 100644 --- a/tests/Feature/BalcaoFlowTest.php +++ b/tests/Feature/BalcaoFlowTest.php @@ -16,6 +16,7 @@ public function testFluxoCompletoDoCadastroDaLojaAoResgate(): void { ]); $this->assertSame('merchant/login&success=cadastrado', $location); $this->loginAs('padaria@teste.test'); + $this->assertSame('merchant/dashboard&success=email_confirmado', $this->confirmEmailFromMail('padaria@teste.test')); // premio [, $location] = $this->post('merchant/rewards', ['action' => 'create', 'name' => 'Cafe', 'points_cost' => 20]); diff --git a/tests/Feature/EmailVerificationTest.php b/tests/Feature/EmailVerificationTest.php new file mode 100644 index 0000000..b5e3d5c --- /dev/null +++ b/tests/Feature/EmailVerificationTest.php @@ -0,0 +1,113 @@ +post('merchant/register', [ + 'owner_name' => 'Dona Ana', 'shop_name' => 'Padaria da Ana', 'address' => 'Rua 1', + 'state' => 'SP', 'city' => 'Sao Paulo', 'email' => $email, + 'phone' => '11988887777', 'category' => 'alimentacao', 'document' => '52998224725', + 'password' => 'teste123', 'password_confirm' => 'teste123', + ]); + $this->assertSame('merchant/login&success=cadastrado', $location); + } + + private function lastToken(): string { + $mails = $this->sentMails(); + preg_match('#verify-email&token=([a-f0-9]{64})#', (string)end($mails)['body'], $m); + $this->assertNotEmpty($m, 'sem link de confirmacao'); + return $m[1]; + } + + public function testCadastroMandaOLinkEOPainelEsperaAConfirmacao(): void { + $this->register(); + + $mails = $this->sentMails(); + $this->assertCount(1, $mails); + $this->assertSame('padaria@teste.test', $mails[0]['to']); + $this->assertStringContainsString('24 horas', $mails[0]['body']); + $this->assertNull($this->scalar('SELECT email_verified_at FROM merchants')); + + // entra, mas toda tela do painel leva pra confirmacao + $this->loginAs('padaria@teste.test'); + foreach (['merchant/dashboard', 'merchant/customers', 'merchant/rewards', 'merchant/profile', 'merchant/export&tipo=clientes'] as $route) { + [$status, $location] = $this->get($route); + $this->assertSame(302, $status, $route); + $this->assertSame('merchant/confirm-email', $location, $route); + } + [$status] = $this->post('merchant/rewards', ['action' => 'create', 'name' => 'Cafe', 'points_cost' => 10], true, 'merchant/confirm-email'); + $this->assertSame(302, $status); + $this->assertSame(0, (int)$this->scalar('SELECT COUNT(*) FROM rewards')); + + $this->get('merchant/confirm-email'); + $this->assertStringContainsString('padaria@teste.test', $this->lastBody); + + // confirma pelo link (logado: volta pro painel) + $this->assertSame('merchant/dashboard&success=email_confirmado', $this->confirmEmailFromMail('padaria@teste.test')); + $this->assertNotNull($this->scalar('SELECT email_verified_at FROM merchants')); + $this->assertSame(200, $this->get('merchant/dashboard')[0]); + [, $location] = $this->get('merchant/confirm-email'); + $this->assertSame('merchant/dashboard', $location, 'ja confirmado nao ve mais a tela'); + } + + public function testLinkAbertoSemLoginConfirmaEUmaVezSo(): void { + $this->register(); + $token = $this->lastToken(); + + $this->newSession(); + [, $location] = $this->get('merchant/verify-email&token=' . $token); + $this->assertSame('merchant/login&success=email_confirmado', $location); + [, $location] = $this->get('merchant/verify-email&token=' . $token); + $this->assertSame('merchant/login&error=confirmacao_invalida', $location); + } + + public function testLinkVencidoOuInventadoNaoConfirma(): void { + $this->register(); + $token = $this->lastToken(); + + $this->assertSame('merchant/login&error=confirmacao_invalida', $this->get('merchant/verify-email&token=' . str_repeat('a', 64))[1]); + $this->assertSame('merchant/login&error=confirmacao_invalida', $this->get('merchant/verify-email')[1]); + + $this->db->exec('UPDATE email_verifications SET expires_at = NOW() - INTERVAL 1 SECOND'); + $this->assertSame('merchant/login&error=confirmacao_invalida', $this->get('merchant/verify-email&token=' . $token)[1]); + $this->assertNull($this->scalar('SELECT email_verified_at FROM merchants')); + } + + public function testReenviarCancelaOLinkAnteriorETemLimite(): void { + $this->register(); + $first = $this->lastToken(); + $this->loginAs('padaria@teste.test'); + + [, $location] = $this->post('merchant/confirm-email', []); + $this->assertSame('merchant/confirm-email&success=confirmacao_reenviada', $location); + $second = $this->lastToken(); + $this->assertNotSame($first, $second); + $this->assertSame('merchant/confirm-email&error=confirmacao_invalida', $this->get('merchant/verify-email&token=' . $first)[1]); + + $this->post('merchant/confirm-email', []); + $this->post('merchant/confirm-email', []); + [, $location] = $this->post('merchant/confirm-email', []); + $this->assertSame('merchant/confirm-email&error=muitos_pedidos', $location); + $this->assertCount(4, $this->sentMails(), 'cadastro + 3 reenvios'); + + [$status] = $this->post('merchant/confirm-email', [], false); + $this->assertSame(403, $status, 'reenvio exige csrf'); + } + + // contas criadas antes da regra (e as de teste, criadas direto no banco) ja contam como confirmadas + public function testContaJaConfirmadaUsaOPainelNormalmente(): void { + $this->createMerchant('loja@teste.test'); + $this->loginAs('loja@teste.test'); + $this->assertSame(200, $this->get('merchant/dashboard')[0]); + $this->assertSame([], $this->sentMails()); + } + + public function testMigration009MarcaContasExistentesComoConfirmadas(): void { + $sql = (string)file_get_contents(dirname(__DIR__, 2) . '/database/migrations/009_email_verifications.sql'); + $this->assertStringContainsString('UPDATE merchants SET email_verified_at = created_at WHERE email_verified_at IS NULL', $sql); + } +} diff --git a/tests/Feature/RegisterTest.php b/tests/Feature/RegisterTest.php index 207fc68..757163a 100644 --- a/tests/Feature/RegisterTest.php +++ b/tests/Feature/RegisterTest.php @@ -35,6 +35,7 @@ public function testCadastraCnpjAlfanumerico(): void { $this->assertNull($this->scalar('SELECT cpf FROM merchants')); $this->loginAs('padaria@teste.test'); + $this->confirmEmailFromMail('padaria@teste.test'); $this->get('merchant/profile'); $this->assertStringContainsString('value="12.ABC.345/01DE-35"', $this->lastBody); $this->assertStringContainsString('>CNPJ<', $this->lastBody); diff --git a/tests/Support/DatabaseTestCase.php b/tests/Support/DatabaseTestCase.php index 9d62e80..ed5fa68 100644 --- a/tests/Support/DatabaseTestCase.php +++ b/tests/Support/DatabaseTestCase.php @@ -19,8 +19,8 @@ protected function setUp(): void { protected function createMerchant(string $email = 'loja@teste.test'): int { $stmt = $this->db->prepare( - "INSERT INTO merchants (public_code, owner_name, store_name, address, state, city, email, phone, category, cpf, password_hash) - VALUES (:code, 'Dona Teste', 'Loja Teste', 'Rua 1', 'SP', 'Sao Paulo', :email, '11988887777', 'varejo', :cpf, :hash)" + "INSERT INTO merchants (public_code, owner_name, store_name, address, state, city, email, email_verified_at, phone, category, cpf, password_hash) + VALUES (:code, 'Dona Teste', 'Loja Teste', 'Rua 1', 'SP', 'Sao Paulo', :email, CURRENT_TIMESTAMP, '11988887777', 'varejo', :cpf, :hash)" ); $stmt->execute([ ':code' => strtoupper(substr(md5($email), 0, 8)), diff --git a/tests/Support/HttpTestCase.php b/tests/Support/HttpTestCase.php index e28df6d..b7ad01f 100644 --- a/tests/Support/HttpTestCase.php +++ b/tests/Support/HttpTestCase.php @@ -97,6 +97,16 @@ protected function sentMails(): array { return \App\Mail\LogMailer::read(self::$mailLog); } + // abre o link do ultimo e-mail de confirmacao mandado para $email (task 50) e devolve o destino do redirect + protected function confirmEmailFromMail(string $email): ?string { + foreach (array_reverse($this->sentMails()) as $mail) { + if ($mail['to'] === $email && preg_match('#url=merchant%2Fverify-email&token=([a-f0-9]{64})#', $mail['body'], $m)) { + return $this->get('merchant/verify-email&token=' . $m[1])[1]; + } + } + $this->fail("nenhum e-mail de confirmacao para $email"); + } + // sessao nova (como apagar o cookie no navegador) protected function newSession(): void { @unlink($this->cookieJar); diff --git a/tests/Support/TestDatabase.php b/tests/Support/TestDatabase.php index c944d35..ef214b5 100644 --- a/tests/Support/TestDatabase.php +++ b/tests/Support/TestDatabase.php @@ -7,7 +7,7 @@ // banco de teste: recriado do schema.sql no inicio da rodada e esvaziado antes de cada teste final class TestDatabase { // ordem respeita as chaves estrangeiras (filhos antes dos pais) - private const TABLES = ['points_log', 'rewards', 'loyalty_cards', 'customers', 'password_resets', 'merchants', 'rate_limit_hits']; + private const TABLES = ['points_log', 'rewards', 'loyalty_cards', 'customers', 'password_resets', 'email_verifications', 'merchants', 'rate_limit_hits']; public static function name(): string { return $_ENV['DB_NAME']; diff --git a/views/merchant/confirm-email.php b/views/merchant/confirm-email.php new file mode 100644 index 0000000..e5df12d --- /dev/null +++ b/views/merchant/confirm-email.php @@ -0,0 +1,16 @@ + + + + +
+

Enviamos um link de confirmação para . Abra o e-mail e clique no link + para liberar o painel (ele vale por 24 horas).

+

Não chegou? Confira a caixa de spam ou peça outro link. O link anterior deixa de valer.

+ +
+ + + +
+ + diff --git a/views/partials/flash.php b/views/partials/flash.php index 353468d..46f45be 100644 --- a/views/partials/flash.php +++ b/views/partials/flash.php @@ -40,12 +40,15 @@ 'estorno_expirado' => 'Só dá para estornar lançamentos e resgates das últimas 24 horas.', 'estorno_sem_saldo' => 'Não dá para estornar: o cliente já usou parte desses pontos.', 'senha_atual_incorreta' => 'A senha atual não confere.', + 'confirmacao_invalida' => 'Este link de confirmação é inválido, já foi usado ou venceu. Entre no painel e peça outro.', 'link_invalido' => 'Este link de nova senha é inválido, já foi usado ou venceu. Peça outro.', 'muitos_pedidos' => 'Muitos pedidos seguidos. Aguarde um pouco e tente de novo.', 'muitas_tentativas' => 'Muitas tentativas com a senha atual errada. Aguarde alguns minutos e tente de novo.', ], 'success' => [ - 'cadastrado' => 'Cadastro realizado! Faça login para continuar.', + 'cadastrado' => 'Cadastro realizado! Enviamos um link de confirmação para o seu e-mail. Faça login para continuar.', + 'confirmacao_reenviada' => 'Enviamos um novo link de confirmação. O anterior deixou de valer.', + 'email_confirmado' => 'E-mail confirmado. Bem-vindo ao FidelityX!', 'logged' => 'Bem-vindo de volta!', 'logout' => 'Você saiu da sua conta.', 'cliente_cadastrado' => 'Cliente cadastrado. Já pode lançar os pontos.', From 7a5510b07b91a21b6d538e60eb4c4b14e92f5982 Mon Sep 17 00:00:00 2001 From: "Emanuel G. Sousa" Date: Tue, 6 Oct 2026 17:42:11 -0300 Subject: [PATCH 13/15] fix(phpstan): tira da baseline o codigo inalcancavel do esqueci a senha handleForgot/handleReset tinham retorno never e o render ainda fazia return depois: codigo novo nao entra na baseline. Task 21. Co-Authored-By: Claude Opus 5.5 --- phpstan-baseline.neon | 6 ------ src/Controllers/MerchantController.php | 4 ++-- 2 files changed, 2 insertions(+), 8 deletions(-) diff --git a/phpstan-baseline.neon b/phpstan-baseline.neon index b76775b..22af72b 100644 --- a/phpstan-baseline.neon +++ b/phpstan-baseline.neon @@ -1,11 +1,5 @@ parameters: ignoreErrors: - - - message: '#^Unreachable statement \- code above always terminates\.$#' - identifier: deadCode.unreachable - count: 2 - path: src/Controllers/MerchantController.php - - message: '#^Variable \$categories might not be defined\.$#' identifier: variable.undefined diff --git a/src/Controllers/MerchantController.php b/src/Controllers/MerchantController.php index d17f64b..8ceb1a7 100644 --- a/src/Controllers/MerchantController.php +++ b/src/Controllers/MerchantController.php @@ -118,7 +118,7 @@ public function renderForgot() { View::render('auth/merchant/forgot'); } - private function handleForgot(): never { + private function handleForgot() { Csrf::verify(); $email = trim((string)($_POST['email'] ?? '')); @@ -172,7 +172,7 @@ public function renderReset() { ]); } - private function handleReset(): never { + private function handleReset() { Csrf::verify(); $token = (string)($_POST['token'] ?? ''); From ee200ba3f460e5d3006eca240bf1de073eb9abb9 Mon Sep 17 00:00:00 2001 From: "Emanuel G. Sousa" Date: Tue, 6 Oct 2026 17:49:09 -0300 Subject: [PATCH 14/15] feat(admin): painel administrativo com login proprio Tabela admins (migration 010), conta criada por bin/create-admin.php, sessao separada da do lojista. O admin ve as lojas (sem dados dos clientes) e ativa ou desativa um lojista, que perde o acesso na hora. Task 30. Co-Authored-By: Claude Opus 5.5 --- CLAUDE.md | 5 +- bin/create-admin.php | 51 +++++++++ database/migrations/010_admins.sql | 21 ++++ database/schema.sql | 20 +++- docs/db/schema-explanation.md | 8 ++ docs/operacao.md | 11 ++ phpstan-baseline.neon | 12 +++ public/index.php | 13 +++ src/Controllers/AdminController.php | 132 +++++++++++++++++++++++ src/Models/AdminModel.php | 52 +++++++++ src/Models/MerchantModel.php | 26 +++++ tests/Feature/AdminTest.php | 158 ++++++++++++++++++++++++++++ tests/Support/TestDatabase.php | 2 +- views/admin/dashboard.php | 75 +++++++++++++ views/admin/login.php | 43 ++++++++ views/partials/flash.php | 3 + 16 files changed, 629 insertions(+), 3 deletions(-) create mode 100644 bin/create-admin.php create mode 100644 database/migrations/010_admins.sql create mode 100644 src/Controllers/AdminController.php create mode 100644 src/Models/AdminModel.php create mode 100644 tests/Feature/AdminTest.php create mode 100644 views/admin/dashboard.php create mode 100644 views/admin/login.php diff --git a/CLAUDE.md b/CLAUDE.md index 09a4079..1599541 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -25,6 +25,7 @@ node tests/js/customer.test.js # conta do valor da compra do public/js/c 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) ``` @@ -36,7 +37,7 @@ Banco: `mysql -u root -p < database/schema.sql` em instalação nova. Banco já ## 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. 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. +**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. @@ -54,6 +55,8 @@ Banco: `mysql -u root -p < database/schema.sql` em instalação nova. Banco já **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. +**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. diff --git a/bin/create-admin.php b/bin/create-admin.php new file mode 100644 index 0000000..32e355d --- /dev/null +++ b/bin/create-admin.php @@ -0,0 +1,51 @@ +create($email, $name, $password); + fwrite(STDOUT, "\nadmin criado (id $id). entre em /index.php?url=admin/login\n"); +} catch (PDOException $e) { + fwrite(STDERR, (($e->errorInfo[1] ?? null) === 1062 ? 'erro: ja existe admin com esse e-mail' : 'erro no banco: ' . $e->getMessage()) . "\n"); + exit(1); +} catch (Throwable $e) { + fwrite(STDERR, 'erro: ' . $e->getMessage() . "\n"); + exit(1); +} diff --git a/database/migrations/010_admins.sql b/database/migrations/010_admins.sql new file mode 100644 index 0000000..eaeb566 --- /dev/null +++ b/database/migrations/010_admins.sql @@ -0,0 +1,21 @@ +-- fidelityx - migration 010 (painel administrativo, task 30) +-- para bancos criados antes desta versao do schema.sql. rodar uma unica vez: +-- php bin/migrate.php +-- (ou, a mao: mysql -u root -p fidelityx < database/migrations/010_admins.sql) + +USE fidelityx; + +-- administradores do FidelityX (nao sao lojistas): login proprio em admin/login, conta criada +-- pela linha de comando (php bin/create-admin.php). veem as lojas, nunca os clientes delas. +CREATE TABLE IF NOT EXISTS admins ( + id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY, + + email VARCHAR(255) NOT NULL, + name VARCHAR(255) NOT NULL, + password_hash VARCHAR(255) NOT NULL, + + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + + UNIQUE KEY uq_admins_email (email) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; diff --git a/database/schema.sql b/database/schema.sql index 3e7dfeb..20309b9 100644 --- a/database/schema.sql +++ b/database/schema.sql @@ -229,6 +229,23 @@ CREATE TABLE IF NOT EXISTS email_verifications ( ON UPDATE CASCADE ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; +-- ========================= +-- admins // administradores do FidelityX (task 30) +-- ========================= +-- login proprio em admin/login, conta criada por php bin/create-admin.php. veem as lojas, nunca os clientes. +CREATE TABLE IF NOT EXISTS admins ( + id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY, + + email VARCHAR(255) NOT NULL, + name VARCHAR(255) NOT NULL, + password_hash VARCHAR(255) NOT NULL, + + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + + UNIQUE KEY uq_admins_email (email) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + -- ========================= -- schema_migrations // quais migrations de database/migrations ja rodaram neste banco -- ========================= @@ -249,4 +266,5 @@ INSERT IGNORE INTO schema_migrations (version) VALUES ('006_points_log_extrato'), ('007_customers_so_telefone'), ('008_password_resets'), - ('009_email_verifications'); + ('009_email_verifications'), + ('010_admins'); diff --git a/docs/db/schema-explanation.md b/docs/db/schema-explanation.md index 6814e92..3b4a7d6 100644 --- a/docs/db/schema-explanation.md +++ b/docs/db/schema-explanation.md @@ -97,3 +97,11 @@ O `schema.sql` já insere todas as migrations existentes: banco novo nasce sem p ### `email_verifications` — links de confirmação do e-mail (task 50) Mesma forma da `password_resets` (`merchant_id`, `token_hash` com `UNIQUE`, `expires_at`, `used_at`), com validade de 24 horas. Reenviar o link cancela o anterior. + +### `admins` — administradores do FidelityX (task 30) +| Coluna | Observação | +|---|---| +| `email` | `UNIQUE`. Login do painel `admin/login`. | +| `password_hash` | bcrypt. A conta é criada por `php bin/create-admin.php`. | + +Separada de `merchants` de propósito: um admin não é uma loja e um lojista nunca vira admin por um campo trocado. diff --git a/docs/operacao.md b/docs/operacao.md index bd411e4..19a656c 100644 --- a/docs/operacao.md +++ b/docs/operacao.md @@ -62,6 +62,17 @@ TRUSTED_PROXIES=127.0.0.1,172.16.0.0/12 Com o Compose, o proxy no host chega ao container pelo gateway da rede do Docker, normalmente um endereço em `172.16.0.0/12`. +## Administrador + +O painel de administração (`/index.php?url=admin/login`) tem contas próprias, que não são lojistas. Para criar uma (no container, `docker compose exec app php bin/create-admin.php ...`): + +```bash +php bin/create-admin.php admin@seudominio.com.br "Nome da pessoa" +# a senha (8 a 72 bytes) é pedida em seguida; não passe a senha como argumento +``` + +O admin vê todas as lojas e ativa ou desativa um lojista (a desativação derruba a sessão dele na hora). Não há tela para criar ou remover admins: remover é `DELETE FROM admins WHERE email = ...` no banco. + ## Rota de saúde `GET /index.php?url=health` responde `200 {"status":"ok"}` quando a aplicação consegue falar com o banco e `503 {"status":"erro"}` quando não (o motivo vai para o log, nunca para a resposta). Ela roda antes da sessão, então checar a cada 30 s não cria arquivo no volume de sessões, e sai com `Cache-Control: no-store`. É o endereço para o monitor externo e para o `HEALTHCHECK` do container (task 56; a troca do healthcheck no `Dockerfile` e o monitor com alerta ainda estão pendentes). diff --git a/phpstan-baseline.neon b/phpstan-baseline.neon index 22af72b..e36a43b 100644 --- a/phpstan-baseline.neon +++ b/phpstan-baseline.neon @@ -1,5 +1,17 @@ parameters: ignoreErrors: + - + message: '#^Variable \$merchants might not be defined\.$#' + identifier: variable.undefined + count: 1 + path: views/admin/dashboard.php + + - + message: '#^Variable \$paginator might not be defined\.$#' + identifier: variable.undefined + count: 3 + path: views/admin/dashboard.php + - message: '#^Variable \$categories might not be defined\.$#' identifier: variable.undefined diff --git a/public/index.php b/public/index.php index ff1c718..1da89fd 100644 --- a/public/index.php +++ b/public/index.php @@ -95,6 +95,19 @@ }; break; + case 'admin': + // painel administrativo (task 30): login e sessao proprios, separados dos do lojista + RequestGuard::sendNoStore(); + $controller = new \App\Controllers\AdminController($db); + + match ($action ?? 'dashboard') { + 'login' => $controller->renderLogin(), + 'logout' => $controller->logout(), + 'dashboard' => $controller->renderDashboard(), + default => (new ErrorController())->handle(404), + }; + break; + case 'merchant': RequestGuard::sendNoStore(); $controller = new \App\Controllers\MerchantController($db); diff --git a/src/Controllers/AdminController.php b/src/Controllers/AdminController.php new file mode 100644 index 0000000..8afdd22 --- /dev/null +++ b/src/Controllers/AdminController.php @@ -0,0 +1,132 @@ +db = $db; + $this->admins = new AdminModel($db); + } + + public function renderLogin() { + if ($_SERVER['REQUEST_METHOD'] === 'POST') { + $this->handleLogin(); + return; + } + if (isset($_SESSION['admin_id'])) { + redirect('admin/dashboard'); + } + View::render('admin/login'); + } + + // mesmas protecoes do login do lojista: limite por e-mail + ip e por ip, mesmo tempo pra e-mail inexistente + private function handleLogin() { + Csrf::verify(); + + $email = trim((string)($_POST['email'] ?? '')); + $password = (string)($_POST['password'] ?? ''); + if ($email === '' || $password === '') { + redirect('admin/login', ['error' => 'campos_obrigatorios']); + } + + $limiter = new RateLimiter($this->db); + $ip = RateLimiter::clientKey(); + $pair = $email . '|' . $ip; + if ($limiter->tooMany('admin_login_pair', $pair, self::MAX_LOGIN_FAILURES, self::LOGIN_WINDOW_SECONDS) + || $limiter->tooMany('admin_login_ip', $ip, self::MAX_LOGIN_FAILURES_PER_IP, self::LOGIN_WINDOW_SECONDS)) { + (new ErrorController())->handle(429); + exit; + } + + $admin = $this->admins->findByEmail($email) ?: null; + if (!LoginGuard::verify($admin, $password)) { + $limiter->hit('admin_login_pair', $pair); + $limiter->hit('admin_login_ip', $ip); + redirect('admin/login', ['error' => 'credenciais_invalidas']); + } + $limiter->clear('admin_login_pair', $pair); + + session_regenerate_id(true); + Csrf::regenerate(); + $_SESSION['admin_id'] = (int)$admin['id']; + $_SESSION['admin_name'] = $admin['name']; + $_SESSION['admin_last_seen'] = time(); + $_SESSION['admin_sig'] = SessionGuard::passwordSignature($admin['password_hash']); + + redirect('admin/dashboard'); + } + + public function logout() { + if ($_SERVER['REQUEST_METHOD'] !== 'POST') { + redirect(isset($_SESSION['admin_id']) ? 'admin/dashboard' : 'admin/login'); + } + Csrf::verify(); + SessionGuard::destroy(); + redirect('admin/login', ['success' => 'logout']); + } + + // lista das lojas, paginada; POST ativa ou desativa uma + public function renderDashboard() { + $this->adminGuard(); + $merchants = new MerchantModel($this->db); + + if ($_SERVER['REQUEST_METHOD'] === 'POST') { + Csrf::verify(); + $merchantId = filter_var($_POST['merchant_id'] ?? '', FILTER_VALIDATE_INT); + $status = (string)($_POST['status'] ?? ''); + $page = filter_var($_POST['page'] ?? '', FILTER_VALIDATE_INT) ?: 1; + + if (!$merchantId || !$merchants->setStatus($merchantId, $status)) { + redirect('admin/dashboard', ['page' => $page, 'error' => 'loja_invalida']); + } + redirect('admin/dashboard', ['page' => $page, 'success' => $status === 'active' ? 'loja_ativada' : 'loja_desativada']); + } + + $paginator = new Paginator($merchants->countAll(), $_GET['page'] ?? 1, self::PER_PAGE); + View::render('admin/dashboard', [ + 'merchants' => $merchants->pageForAdmin($paginator->perPage, $paginator->offset()), + 'paginator' => $paginator, + ]); + } + + // barra quem nao e admin logado; a cada requisicao confere se o admin existe, se a sessao nao + // ficou parada demais e se a senha nao mudou desde o login + private function adminGuard(): int { + if (!isset($_SESSION['admin_id'])) { + redirect('admin/login', ['error' => 'sessao_expirada']); + } + + $admin = $this->admins->findById((int)$_SESSION['admin_id']); + if (!$admin + || SessionGuard::isExpired($_SESSION['admin_last_seen'] ?? null, time(), self::IDLE_SECONDS) + || SessionGuard::passwordChanged($_SESSION['admin_sig'] ?? null, $admin['password_hash'])) { + SessionGuard::destroy(); + redirect('admin/login', ['error' => 'sessao_expirada']); + } + + $_SESSION['admin_last_seen'] = time(); + return (int)$admin['id']; + } +} diff --git a/src/Models/AdminModel.php b/src/Models/AdminModel.php new file mode 100644 index 0000000..cd48203 --- /dev/null +++ b/src/Models/AdminModel.php @@ -0,0 +1,52 @@ +db = $db; + } + + public function findByEmail(string $email) { + $stmt = $this->db->prepare('SELECT id, name, email, password_hash FROM admins WHERE email = :email'); + $stmt->execute([':email' => $email]); + return $stmt->fetch(\PDO::FETCH_ASSOC); + } + + public function findById(int $id) { + $stmt = $this->db->prepare('SELECT id, name, email, password_hash FROM admins WHERE id = :id'); + $stmt->execute([':id' => $id]); + return $stmt->fetch(\PDO::FETCH_ASSOC); + } + + // o que esta errado nos dados de um admin novo (texto pra linha de comando) ou null se esta tudo certo + public static function problem(string $email, string $name, string $password): ?string { + if (!filter_var($email, FILTER_VALIDATE_EMAIL) || strlen($email) > 255) { + return 'e-mail invalido'; + } + if (trim($name) === '' || mb_strlen($name) > 255) { + return 'informe o nome (ate 255 caracteres)'; + } + if (PasswordPolicy::problem($password) !== null) { + return 'a senha precisa ter de ' . PasswordPolicy::MIN_BYTES . ' a ' . PasswordPolicy::MAX_BYTES . ' bytes'; + } + return null; + } + + // cria o admin e devolve o id. e-mail repetido sobe como PDOException (UNIQUE). + public function create(string $email, string $name, string $password): int { + $problem = self::problem($email, $name, $password); + if ($problem !== null) { + throw new \InvalidArgumentException($problem); + } + + $stmt = $this->db->prepare('INSERT INTO admins (email, name, password_hash) VALUES (:email, :name, :hash)'); + $stmt->execute([':email' => $email, ':name' => trim($name), ':hash' => password_hash($password, PASSWORD_BCRYPT)]); + return (int)$this->db->lastInsertId(); + } +} diff --git a/src/Models/MerchantModel.php b/src/Models/MerchantModel.php index bc95c63..04dfd15 100644 --- a/src/Models/MerchantModel.php +++ b/src/Models/MerchantModel.php @@ -107,4 +107,30 @@ public function findActiveByPublicCode(string $code) { return $stmt->fetch(\PDO::FETCH_ASSOC); } + + // painel administrativo (task 30): as lojas, da mais nova pra mais antiga, com o numero de clientes. + // so dados da loja: nada de cliente (nome, telefone) sai daqui. + public function countAll(): int { + return (int)$this->db->query('SELECT COUNT(*) FROM merchants')->fetchColumn(); + } + + public function pageForAdmin(int $limit, int $offset): array { + $sql = 'SELECT m.id, m.store_name, m.owner_name, m.email, m.cpf, m.cnpj, m.status, m.created_at, + (SELECT COUNT(*) FROM loyalty_cards lc WHERE lc.merchant_id = m.id AND lc.anonymized_at IS NULL) AS customers + FROM merchants m + ORDER BY m.created_at DESC, m.id DESC + LIMIT ' . (int)$limit . ' OFFSET ' . (int)$offset; + return $this->db->query($sql)->fetchAll(\PDO::FETCH_ASSOC); + } + + // ativa ou desativa a loja. desativada perde o acesso na hora (o authGuard confere o status a cada requisicao). + // devolve false se a loja nao existe. + public function setStatus(int $merchantId, string $status): bool { + if (!in_array($status, ['active', 'inactive'], true)) { + return false; + } + $stmt = $this->db->prepare('UPDATE merchants SET status = :status WHERE id = :id'); + $stmt->execute([':status' => $status, ':id' => $merchantId]); + return $stmt->rowCount() > 0 || $this->findById($merchantId) !== false; + } } diff --git a/tests/Feature/AdminTest.php b/tests/Feature/AdminTest.php new file mode 100644 index 0000000..ced5348 --- /dev/null +++ b/tests/Feature/AdminTest.php @@ -0,0 +1,158 @@ +db))->create($email, 'Admin Teste', $password); + } + + private function loginAdmin(string $email = 'admin@fidelityx.test', string $password = 'admin-senha-1'): ?string { + return $this->post('admin/login', ['email' => $email, 'password' => $password])[1]; + } + + private function merchantStatus(int $merchantId): string { + return (string)$this->scalar('SELECT status FROM merchants WHERE id = ?', [$merchantId]); + } + + public function testPainelExigeLoginDeAdmin(): void { + [$status, $location] = $this->get('admin/dashboard'); + $this->assertSame(302, $status); + $this->assertSame('admin/login&error=sessao_expirada', $location); + $this->assertSame('no-store', $this->lastHeaders['cache-control'][0]); + + // lojista logado nao e admin + $this->createMerchant('loja@teste.test'); + $this->loginAs('loja@teste.test'); + $this->assertSame('admin/login&error=sessao_expirada', $this->get('admin/dashboard')[1]); + } + + public function testLoginDeAdmin(): void { + $this->createAdmin(); + // senha de lojista nao vale no admin (outra tabela) + $this->createMerchant('admin@fidelityx.test'); + + $this->assertSame('admin/login&error=credenciais_invalidas', $this->loginAdmin('admin@fidelityx.test', 'teste123')); + $this->assertSame('admin/login&error=credenciais_invalidas', $this->loginAdmin('ninguem@fidelityx.test')); + $this->assertSame('admin/dashboard', $this->loginAdmin()); + $this->assertSame(200, $this->get('admin/dashboard')[0]); + $this->assertStringContainsString('Administração · Admin Teste', $this->lastBody); + } + + public function testListaAsLojasSemDadosDosClientes(): void { + $this->createAdmin(); + $loja = $this->createMerchant('loja@teste.test'); + $this->createCard($loja, 'Bia Cliente', '11922220002'); + $this->createCard($loja, 'Caio Cliente', '11933330003'); + $this->createMerchant('outra@teste.test'); + + $this->loginAdmin(); + $this->get('admin/dashboard'); + + $this->assertStringContainsString('2 lojas', $this->lastBody); + $this->assertStringContainsString('loja@teste.test', $this->lastBody); + $this->assertStringContainsString('outra@teste.test', $this->lastBody); + $this->assertMatchesRegularExpression('#loja@teste\.test\s*
#', $this->lastBody); + $this->assertStringNotContainsString('Bia Cliente', $this->lastBody); + $this->assertStringNotContainsString('92222', $this->lastBody); + } + + public function testDesativarDerrubaOLojistaNaHoraEAtivarDevolveOAcesso(): void { + $this->createAdmin(); + $loja = $this->createMerchant('loja@teste.test'); + + // o lojista esta logado em outro aparelho + $this->loginAs('loja@teste.test'); + $merchantSession = $this->cookie('PHPSESSID'); + + $this->newSession(); + $this->loginAdmin(); + [, $location] = $this->post('admin/dashboard', ['merchant_id' => $loja, 'status' => 'inactive', 'page' => 1]); + $this->assertSame('admin/dashboard&page=1&success=loja_desativada', $location); + $this->assertSame('inactive', $this->merchantStatus($loja)); + + $this->newSession(); + $this->setCookie('PHPSESSID', (string)$merchantSession); + $this->assertSame('merchant/login&error=conta_inativa', $this->get('merchant/dashboard')[1]); + + $this->newSession(); + $this->loginAdmin(); + [, $location] = $this->post('admin/dashboard', ['merchant_id' => $loja, 'status' => 'active', 'page' => 1]); + $this->assertSame('admin/dashboard&page=1&success=loja_ativada', $location); + $this->newSession(); + $this->loginAs('loja@teste.test'); + } + + public function testAcaoInvalidaOuSemCsrfNaoMudaNada(): void { + $this->createAdmin(); + $loja = $this->createMerchant('loja@teste.test'); + $this->loginAdmin(); + + $this->assertSame('admin/dashboard&page=1&error=loja_invalida', $this->post('admin/dashboard', ['merchant_id' => $loja, 'status' => 'apagada'])[1]); + $this->assertSame('admin/dashboard&page=1&error=loja_invalida', $this->post('admin/dashboard', ['merchant_id' => 999999, 'status' => 'inactive'])[1]); + + [$status] = $this->post('admin/dashboard', ['merchant_id' => $loja, 'status' => 'inactive'], false); + $this->assertSame(403, $status); + $this->assertSame('active', $this->merchantStatus($loja)); + } + + public function testSessaoDeAdminCaiQuandoASenhaMudaENoLogout(): void { + $id = $this->createAdmin(); + $this->loginAdmin(); + + [, $location] = $this->post('admin/logout', [], true, 'admin/dashboard'); + $this->assertSame('admin/login&success=logout', $location); + $this->assertSame('admin/login&error=sessao_expirada', $this->get('admin/dashboard')[1]); + + $this->loginAdmin(); + $this->db->prepare('UPDATE admins SET password_hash = ? WHERE id = ?')->execute([password_hash('outra-senha-1', PASSWORD_BCRYPT), $id]); + $this->assertSame('admin/login&error=sessao_expirada', $this->get('admin/dashboard')[1]); + } + + public function testErrosDemaisNoLoginDeAdminDao429(): void { + $this->createAdmin(); + for ($i = 0; $i < 5; $i++) { + $this->assertSame('admin/login&error=credenciais_invalidas', $this->loginAdmin('admin@fidelityx.test', 'errada-123')); + } + [$status] = $this->post('admin/login', ['email' => 'admin@fidelityx.test', 'password' => 'admin-senha-1']); + $this->assertSame(429, $status); + } + + // o script de linha de comando cria o admin (aqui, no banco de teste) + public function testScriptCriaOAdmin(): void { + $env = getenv(); + foreach (['DB_HOST', 'DB_PORT', 'DB_NAME', 'DB_USER', 'DB_PASS'] as $key) { + $env[$key] = (string)($_ENV[$key] ?? ''); + } + $run = function (array $args, string $input) use ($env): array { + $process = proc_open( + array_merge([PHP_BINARY, dirname(__DIR__, 2) . '/bin/create-admin.php'], $args), + [0 => ['pipe', 'r'], 1 => ['pipe', 'w'], 2 => ['pipe', 'w']], + $pipes, + null, + $env + ); + fwrite($pipes[0], $input); + fclose($pipes[0]); + $out = stream_get_contents($pipes[1]) . stream_get_contents($pipes[2]); + return [proc_close($process), $out]; + }; + + [$code, $out] = $run(['novo@fidelityx.test', 'Nova Admin'], "curta\n"); + $this->assertSame(1, $code); + $this->assertStringContainsString('a senha precisa ter de 8 a 72 bytes', $out); + + [$code, $out] = $run(['novo@fidelityx.test', 'Nova Admin'], "senha-forte-1\n"); + $this->assertSame(0, $code, $out); + $this->assertSame('Nova Admin', $this->scalar("SELECT name FROM admins WHERE email = 'novo@fidelityx.test'")); + $this->assertSame('admin/dashboard', $this->loginAdmin('novo@fidelityx.test', 'senha-forte-1')); + + [$code, $out] = $run(['novo@fidelityx.test', 'Nova Admin'], "senha-forte-1\n"); + $this->assertSame(1, $code); + $this->assertStringContainsString('ja existe admin com esse e-mail', $out); + } +} diff --git a/tests/Support/TestDatabase.php b/tests/Support/TestDatabase.php index ef214b5..33732f5 100644 --- a/tests/Support/TestDatabase.php +++ b/tests/Support/TestDatabase.php @@ -7,7 +7,7 @@ // banco de teste: recriado do schema.sql no inicio da rodada e esvaziado antes de cada teste final class TestDatabase { // ordem respeita as chaves estrangeiras (filhos antes dos pais) - private const TABLES = ['points_log', 'rewards', 'loyalty_cards', 'customers', 'password_resets', 'email_verifications', 'merchants', 'rate_limit_hits']; + private const TABLES = ['points_log', 'rewards', 'loyalty_cards', 'customers', 'password_resets', 'email_verifications', 'merchants', 'rate_limit_hits', 'admins']; public static function name(): string { return $_ENV['DB_NAME']; diff --git a/views/admin/dashboard.php b/views/admin/dashboard.php new file mode 100644 index 0000000..179c238 --- /dev/null +++ b/views/admin/dashboard.php @@ -0,0 +1,75 @@ + + + + + + + + Lojas | Administração FidelityX + + + + + + + + +
+

Lojas

+ + +
+ +

Nenhuma loja cadastrada.

+ +

total ?> total === 1 ? 'loja' : 'lojas' ?>

+
+
2
+ + + + + + + + + + + + + + + + + + +
LojaResponsávelCPF/CNPJE-mailClientesCadastroSituaçãoAções
+
+ + + + + +
+
+
+ + + + +
+ + + diff --git a/views/admin/login.php b/views/admin/login.php new file mode 100644 index 0000000..1db1814 --- /dev/null +++ b/views/admin/login.php @@ -0,0 +1,43 @@ + + + + + + + + Administração | FidelityX + + + + + + + +
+
+ +

Administração

+
+ + + +
+ + +
+ + +
+ +
+ + +
+ + +
+
+ + + + diff --git a/views/partials/flash.php b/views/partials/flash.php index 46f45be..034b6fb 100644 --- a/views/partials/flash.php +++ b/views/partials/flash.php @@ -40,6 +40,7 @@ 'estorno_expirado' => 'Só dá para estornar lançamentos e resgates das últimas 24 horas.', 'estorno_sem_saldo' => 'Não dá para estornar: o cliente já usou parte desses pontos.', 'senha_atual_incorreta' => 'A senha atual não confere.', + 'loja_invalida' => 'Loja não encontrada.', 'confirmacao_invalida' => 'Este link de confirmação é inválido, já foi usado ou venceu. Entre no painel e peça outro.', 'link_invalido' => 'Este link de nova senha é inválido, já foi usado ou venceu. Peça outro.', 'muitos_pedidos' => 'Muitos pedidos seguidos. Aguarde um pouco e tente de novo.', @@ -48,6 +49,8 @@ 'success' => [ 'cadastrado' => 'Cadastro realizado! Enviamos um link de confirmação para o seu e-mail. Faça login para continuar.', 'confirmacao_reenviada' => 'Enviamos um novo link de confirmação. O anterior deixou de valer.', + 'loja_ativada' => 'Loja ativada. O lojista já pode entrar.', + 'loja_desativada' => 'Loja desativada. O lojista perdeu o acesso na hora.', 'email_confirmado' => 'E-mail confirmado. Bem-vindo ao FidelityX!', 'logged' => 'Bem-vindo de volta!', 'logout' => 'Você saiu da sua conta.', From 51731a7e60ad6f95c3b04c7e79a05abeefbcd7f1 Mon Sep 17 00:00:00 2001 From: "Emanuel G. Sousa" Date: Tue, 6 Oct 2026 17:53:46 -0300 Subject: [PATCH 15/15] docs(adr): proposta de atendentes com login proprio ADR 003 em status "proposta": tabela users com papel dono/atendente, permissoes por rota, autor de cada movimentacao no points_log e perguntas em aberto para o time. Sem codigo. Task 58. Co-Authored-By: Claude Opus 5.5 --- docs/adr/003-atendentes-com-login-proprio.md | 81 ++++++++++++++++++++ 1 file changed, 81 insertions(+) create mode 100644 docs/adr/003-atendentes-com-login-proprio.md diff --git a/docs/adr/003-atendentes-com-login-proprio.md b/docs/adr/003-atendentes-com-login-proprio.md new file mode 100644 index 0000000..e157149 --- /dev/null +++ b/docs/adr/003-atendentes-com-login-proprio.md @@ -0,0 +1,81 @@ +# ADR 003 — Atendentes com login próprio + +Status: **proposta** (2026-10-06, rascunho para o time decidir) · Task 58 do backlog · Sem código ainda + +## Problema encontrado + +A loja tem uma senha só. Quem está no balcão usa a conta do dono, então: + +1. **Não fica registrado quem fez o quê.** `points_log.responsible_user` existe desde o MVP, mas nunca foi preenchido. Um lançamento errado, um resgate indevido ou um estorno não têm autor. +2. **Todo mundo pode tudo.** Quem lança pontos também apaga prêmio, muda a regra de pontos, troca a senha da conta e exclui dados de cliente. +3. **Tirar o acesso de alguém custa caro.** Quando um funcionário sai, o dono precisa trocar a senha e repassar a nova para todos os outros. + +## Decisão proposta + +### 1. Quem entra vira "usuário"; a loja continua sendo a loja + +Tabela nova `users`, com e-mail e senha saindo de `merchants`: + +| Coluna | Observação | +|---|---| +| `id` | | +| `merchant_id` | Loja a que o usuário pertence (FK). Um usuário, uma loja. Ver "Filiais" abaixo. | +| `role` | `owner` (dono) ou `attendant` (atendente). | +| `name`, `email` | E-mail `UNIQUE` na plataforma inteira: o login continua sendo só e-mail + senha. | +| `password_hash`, `email_verified_at`, `status` | Os mesmos de hoje em `merchants`, agora por pessoa. `status` = `active`/`inactive`. | + +A migration cria um `owner` para cada loja existente, copiando `owner_name`, `email`, `password_hash` e `email_verified_at`, e só depois tira essas colunas de `merchants`. `merchants.status` continua valendo para a loja inteira: o admin (task 30) desativa a loja, e o dono desativa um atendente. + +**Por que uma tabela única e não uma tabela `attendants` ao lado de `merchants`:** com duas tabelas, cada login, sessão, limite de tentativas, recuperação de senha (task 21), confirmação de e-mail (task 50) e o autor de cada movimentação precisariam saber "de qual tabela" é a pessoa. Com `users`, tudo isso passa a apontar para um `user_id`, e o caso do dono deixa de ser especial. + +### 2. Sessão e permissões + +- A sessão guarda `user_id`, `merchant_id` e `role`. O `authGuard` passa a conferir, a cada requisição, se o usuário **e** a loja estão ativos (desativar um atendente derruba a sessão dele na hora, como hoje acontece com a loja). +- `password_sig` passa a ser do usuário: trocar a senha de um atendente não derruba o dono. +- Um método `requireOwner()` no controller, chamado no começo das rotas só do dono. Rota nova nasce restrita ao dono; liberar para atendente é decisão explícita. + +| Ação | Dono | Atendente | +|---|---|---| +| Buscar cliente, cadastro rápido, lançar, resgatar, estornar (24 h) | sim | sim | +| Extrato do cliente, registrar consentimento | sim | sim | +| Corrigir nome do cliente (task 43) | sim | sim *(a confirmar)* | +| Trocar telefone do cliente (task 43) | sim | não *(a confirmar)* | +| Excluir dados do cliente (LGPD) | sim | não | +| Prêmios, regra de pontos, perfil da loja, cartaz | sim | não | +| Relatórios e exportação CSV (task 59) | sim | não *(a confirmar)* | +| Cadastrar, desativar e reativar atendentes | sim | não | + +### 3. Quem fez cada movimentação + +`points_log.responsible_user VARCHAR(255)` vira `user_id BIGINT UNSIGNED NULL` com FK para `users` (`ON DELETE SET NULL`). `addPoints`, `redeem` e `reverse` recebem o `user_id` da sessão e gravam na mesma transação. Movimentações antigas ficam com `NULL` e aparecem como "—". O extrato, o histórico dos relatórios e o CSV ganham a coluna "Feito por". + +O nome mostrado é o do usuário **hoje** (não uma cópia na hora do lançamento): se o atendente for removido (ver LGPD), o histórico passa a mostrar "Atendente removido". + +### 4. Cadastro de atendente + +O dono informa nome e e-mail numa tela nova (`merchant/team`). O sistema cria o usuário sem senha e manda por e-mail um link para ele criar a própria senha, reaproveitando o `MerchantTokenModel` (tasks 21 e 50): o dono nunca sabe a senha do atendente, e o e-mail já sai confirmado pelo uso do link. **Depende do provedor de e-mail (task 55)**, que ainda não foi escolhido. + +### 5. LGPD + +Os atendentes são titulares de dados (nome e e-mail) e a loja é a controladora. Desativar mantém o registro (o histórico precisa do autor). Para atender um pedido de exclusão, o dono "remove" o atendente: o usuário fica `inactive`, com nome trocado por "Atendente removido" e e-mail por um valor inválido único, e o `user_id` continua nas movimentações. Nenhuma tela de uma loja mostra atendente de outra. + +## Consequências + +- **Migration grande e com cópia de dados** (`merchants` → `users`). Precisa de teste de migration sobre um banco no formato antigo, como a 007. +- **Login, recuperação de senha, confirmação de e-mail e limite de tentativas** passam a falar de usuário, não de loja. Os buckets do `RateLimiter` continuam por e-mail, então nada muda para quem usa. +- **Toda rota do painel ganha uma decisão de permissão.** A tabela acima vira teste de fluxo: atendente recebe 403 em cada rota só do dono. +- **Abre caminho para filiais (task 60):** "um usuário, várias lojas" vira uma tabela de vínculo `user_stores` no lugar de `users.merchant_id`, sem mexer em login nem em `points_log`. + +## Alternativas descartadas + +- **Tabela `attendants` separada, dono continua em `merchants`:** menos migration agora, mas dois caminhos em tudo que envolve login e autoria (ver item 1). +- **PIN de 4 dígitos por atendente, sem login:** registra o autor e é rápido no balcão, mas a sessão continua sendo a do dono (o atendente vê e faz tudo) e PIN curto é fácil de adivinhar. +- **Só um campo "atendente" de texto livre no lançamento:** não controla acesso e qualquer um digita qualquer nome. + +## Perguntas em aberto (para o time) + +1. Atendente pode corrigir nome, trocar telefone e ver relatórios? (marcados "a confirmar" na tabela) +2. Limite de atendentes por loja? Liga com os planos Free/Pro (task 32). +3. Dono pode redefinir a senha de um atendente, ou só reenviar o link para ele criar outra? +4. A sessão do atendente expira em menos tempo que a do dono (hoje 8 h)? +5. Sem provedor de e-mail (task 55), aceitamos um caminho provisório (dono define a senha inicial e o atendente é obrigado a trocar no primeiro acesso)?