diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1d9d8e4..6ed5048 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -72,6 +72,9 @@ jobs: - name: Testes das mascaras (JavaScript) run: node tests/js/masks.test.js + - name: Testes do envio de formulario (JavaScript) + run: node tests/js/forms.test.js + # o public/js/masks.js e versionado e gerado do src/ts/masks.ts: se alguem mexer no .ts e esquecer # de rodar o tsc (ou editar o .js na mao), o compilado deixa de bater com o fonte e o job falha. mascaras: diff --git a/CLAUDE.md b/CLAUDE.md index 2c8eb99..8f316e8 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -20,6 +20,7 @@ composer test -- --testsuite Unit # só os testes sem banco 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/expire-points.php # vence o saldo parado alem do prazo de cada loja (--dry-run so mostra) docker compose up -d --build # aplicacao + mysql em docker (docs/operacao.md) ``` @@ -35,9 +36,9 @@ Banco: `mysql -u root -p < database/schema.sql` em instalação nova. Banco já **Padrão de controller** — cada rota tem um `renderX()` público: no GET chama `View::render(...)`, no POST delega para um `handleX()` privado. Todo `handle*` começa com `Csrf::verify()` (aborta com 403). Rotas privadas começam com `$merchantId = $this->authGuard()`; o `merchant_id` vem **sempre da sessão**, nunca do formulário, e todo model filtra por ele (isolamento entre lojas). -**Post/Redirect/Get com códigos de flash** — handlers terminam com `redirect('rota', ['error' => 'codigo'])` ou `['success' => 'codigo']` (`redirect()` tem tipo `never`, faz `exit`). `views/partials/flash.php` traduz o código via um mapa fixo; **código novo precisa ser adicionado nesse mapa**, senão nada aparece. Nunca exibir o valor cru da query string. +**Post/Redirect/Get com códigos de flash** — handlers terminam com `redirect('rota', ['error' => 'codigo'])` ou `['success' => 'codigo']` (`redirect()` tem tipo `never`, faz `exit`). `views/partials/flash.php` traduz o código via um mapa fixo; **código novo precisa ser adicionado nesse mapa**, senão nada aparece. Erro que aponta um campo também entra no mapa de `field_error_attr()` (`helpers.php`), e o `` leva `= field_error_attr('nome_do_campo') ?>`: o campo sai com `aria-invalid` (borda de erro) ligado à mensagem. Nunca exibir o valor cru da query string. -**Views** — PHP puro em `views/`, recebem variáveis via `extract()` do `View::render($view, $data)`. Toda saída passa por `e()`. Links e actions usam `url('rota', [...])`; formulários POST incluem `= Csrf::field() ?>`. Páginas do painel incluem `partials/merchant-header.php` / `merchant-footer.php` (definir `$title` antes). Helpers globais (`e`, `redirect`, `url`, `format_phone`, `format_document`, `format_datetime`) ficam em `src/Support/helpers.php`, carregado pelo autoload `files` do Composer. +**Views** — PHP puro em `views/`, recebem variáveis via `extract()` do `View::render($view, $data)`. Toda saída passa por `e()`. Links e actions usam `url('rota', [...])`; formulários POST incluem `= Csrf::field() ?>`. Páginas do painel incluem `partials/merchant-header.php` / `merchant-footer.php` (definir `$title` antes). Helpers globais (`e`, `redirect`, `url`, `format_phone`, `format_document`, `format_datetime`, `format_date`) ficam em `src/Support/helpers.php`, carregado pelo autoload `files` do Composer. **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. @@ -53,7 +54,8 @@ Banco: `mysql -u root -p < database/schema.sql` em instalação nova. Banco já - `customers` é **só o telefone**, global e único (só dígitos, 10–11, via `PhoneValidator::sanitize`); o mesmo cliente pode ter cartão em várias lojas. - `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. +- `points_log` é o histórico (`earn`/`redeem`/`reversal`/`expire`, `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. +- **Validade dos pontos (task 31)**: cada loja tem `merchants.points_expiry_months` (padrão 12, `NULL` = não vence, escolhido na tela Regra de pontos). O saldo inteiro vence depois desse tempo sem movimentação, contado de `loyalty_cards.last_use_at`; a data não é gravada, é sempre calculada. Quem vence é `LoyaltyCardModel::expire` / `expireIdle` (linha `expire` no `points_log`), chamado pelo `bin/expire-points.php`, que precisa estar agendado (`docs/operacao.md`). A data aparece para o cliente e para o lojista pelo `views/partials/points-expiry.php`, a partir do `expires_at` de `findByMerchantAndPhone`. - `merchants` guarda CPF **ou** CNPJ (normalizados, UNIQUE). 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). @@ -69,6 +71,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` (compilado para `public/js/forms.js`, também versionado) vale para todo `