Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
9 changes: 6 additions & 3 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
```

Expand All @@ -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 `<input>` 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.

Expand All @@ -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).
Expand All @@ -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 `<form>`: no envio, o botão clicado ganha a classe `is-loading` (`components.css`) e um segundo envio do mesmo formulário é barrado. Página nova com formulário carrega `/js/forms.js`; o botão não recebe `disabled`.

## Backlog

Expand Down
54 changes: 54 additions & 0 deletions bin/expire-points.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
<?php
// vence o saldo de pontos parado alem do prazo de cada loja (merchants.points_expiry_months, task 31).
//
// php bin/expire-points.php vence os saldos e registra cada um no historico (points_log 'expire')
// php bin/expire-points.php --dry-run so mostra quantos cartoes e pontos venceriam, sem alterar nada
//
// agendar uma vez por dia (cron, agendador de tarefas). pode rodar quantas vezes quiser: saldo ja vencido
// nao vence de novo. sai com codigo 1 se falhar, pra o agendador avisar. ver docs/operacao.md.

require __DIR__ . '/../vendor/autoload.php';

use App\Database;
use App\Models\LoyaltyCardModel;
use App\Support\Env;
use App\Support\ErrorLog;

if (PHP_SAPI !== 'cli') {
exit(1);
}

Env::load(dirname(__DIR__));
date_default_timezone_set(app_timezone());
$logToFile = ErrorLog::useFile(Env::get('LOG_FILE'));

$option = $argv[1] ?? '';

try {
if ($option !== '' && $option !== '--dry-run') {
throw new RuntimeException("opcao desconhecida: $option (use sem nada ou com --dry-run)");
}
if (Env::missing() !== []) {
throw new RuntimeException('variaveis obrigatorias sem valor: ' . implode(', ', Env::missing()));
}

$cards = new LoyaltyCardModel(Database::connect());

if ($option === '--dry-run') {
$preview = $cards->countExpirable();
printf("venceriam agora: %d cartao(oes), %d ponto(s). nada foi alterado.\n", $preview['cards'], $preview['points']);
exit(0);
}

$result = $cards->expireIdle();
echo $result['cards'] === 0
? "nada a vencer.\n"
: sprintf("%d cartao(oes) com saldo vencido, %d ponto(s) no total.\n", $result['cards'], $result['points']);
} catch (Throwable $e) {
// vai pro terminal e, se houver LOG_FILE, pro log de erros (o agendador nem sempre guarda a saida)
fwrite(STDERR, 'erro ao vencer pontos: ' . $e->getMessage() . "\n");
if ($logToFile) {
error_log('[expire-points] ' . $e->getMessage());
}
exit(1);
}
16 changes: 16 additions & 0 deletions database/migrations/007_validade_pontos.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
-- fidelityx - migration 007 (validade dos pontos, task 31)
-- 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_validade_pontos.sql)

USE fidelityx;

-- prazo de validade por loja: o saldo do cliente vence depois de points_expiry_months meses sem
-- nenhuma movimentacao naquela loja (loyalty_cards.last_use_at). NULL = os pontos nao vencem.
-- o padrao e 12 meses, e as lojas que ja existem tambem comecam com ele.
ALTER TABLE merchants
ADD COLUMN points_expiry_months TINYINT UNSIGNED NULL DEFAULT 12 AFTER points_rule_cents;

-- o vencimento vira uma linha propria no historico ('expire'), como o resgate e o estorno
ALTER TABLE points_log
MODIFY type ENUM('earn', 'redeem', 'reversal', 'expire') NOT NULL;
12 changes: 9 additions & 3 deletions database/schema.sql
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,9 @@ CREATE TABLE IF NOT EXISTS merchants (
-- regra de pontos pelo valor da compra: a cada points_rule_cents centavos, 1 ponto (arredonda pra baixo).
-- NULL = sem regra (o lojista digita os pontos direto)
points_rule_cents INT UNSIGNED NULL,
-- validade dos pontos: o saldo vence depois deste tanto de meses sem movimentacao do cliente na loja
-- (loyalty_cards.last_use_at). NULL = os pontos nao vencem
points_expiry_months TINYINT UNSIGNED NULL DEFAULT 12,
status ENUM('active', 'inactive') NOT NULL DEFAULT 'active',

-- para futuras implementações (NULL)
Expand Down Expand Up @@ -89,8 +92,10 @@ CREATE TABLE IF NOT EXISTS loyalty_cards (
current_points INT NOT NULL DEFAULT 0,
total_accumulated INT NOT NULL DEFAULT 0,

-- para futuras implementações (NULL)
-- ultima movimentacao (ganho, resgate ou estorno). e daqui que conta a validade dos pontos:
-- vencimento = last_use_at + merchants.points_expiry_months (calculado na hora, nao fica gravado)
last_use_at TIMESTAMP NULL,
-- sem uso: a data de vencimento e calculada (acima), pra nao ficar errada quando a loja muda o prazo
expiration_date TIMESTAMP NULL,

created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
Expand Down Expand Up @@ -140,7 +145,7 @@ 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', 'expire') NOT NULL, -- reversal = estorno de um earn, expire = saldo vencido
quantity INT NOT NULL,
description VARCHAR(255) NOT NULL,
reward_id BIGINT UNSIGNED NULL, -- preenchido quando type = 'redeem'
Expand Down Expand Up @@ -207,4 +212,5 @@ INSERT IGNORE INTO schema_migrations (version) VALUES
('003_lgpd'),
('004_estorno'),
('005_regra_pontos'),
('006_points_log_extrato');
('006_points_log_extrato'),
('007_validade_pontos');
6 changes: 4 additions & 2 deletions docs/db/schema-explanation.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ merchants 1───N loyalty_cards N───1 customers
| Coluna | Observação |
|---|---|
| `points_rule_cents` | Regra de pontos pelo valor da compra: a cada tantos centavos, 1 ponto (sempre arredonda para baixo, `Money::pointsFor`). `NULL` = sem regra, o lojista digita os pontos. |
| `points_expiry_months` | Validade dos pontos (task 31): o saldo do cliente vence depois deste tanto de meses sem movimentação na loja, contados de `loyalty_cards.last_use_at`. Padrão 12; `NULL` = os pontos não vencem. O lojista escolhe na tela Regra de pontos, entre os prazos de `MerchantController::EXPIRY_MONTHS_OPTIONS`. |
| `public_code` | `UNIQUE`, 8 caracteres. Código público da loja: vai no QR do cartaz e identifica a loja na consulta de saldo. Gerado no cadastro (`App\Support\PublicCode`). |
| `email`, `cpf`, `cnpj` | `UNIQUE`. Só um dos dois documentos é preenchido, apenas com números. |
| `password_hash` | BCRYPT (`password_hash`). |
Expand All @@ -47,7 +48,8 @@ merchants 1───N loyalty_cards N───1 customers
| `anonymized_at` | Exclusão a pedido do cliente: o cartão perde nome, telefone, consentimento e saldo e fica só para os relatórios. |
| `current_points` | Saldo disponível. Aumenta ao ganhar pontos e diminui ao resgatar. |
| `total_accumulated` | Tudo que o cliente já ganhou nesta loja. Só aumenta. |
| `last_use_at` | Última movimentação. |
| `last_use_at` | Última movimentação (ganho, resgate ou estorno). É daqui que conta a validade dos pontos: vencimento = `last_use_at` + `merchants.points_expiry_months`. |
| `expiration_date` | Sem uso. A data de vencimento é calculada na hora (linha acima) em vez de gravada, para não ficar errada quando a loja muda o prazo. |

### `rewards` — catálogo de prêmios (novo no MVP)
| Coluna | Observação |
Expand All @@ -59,7 +61,7 @@ merchants 1───N loyalty_cards N───1 customers
### `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), `reversal` (estorno de um ganho) ou `expire` (saldo vencido por falta de movimentação). `quantity` é sempre positivo. |
| `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. |
| `ip_address` | IP de quem fez a operação (o do cliente, mesmo atrás de proxy confiável: `TRUSTED_PROXIES`). |
Expand Down
28 changes: 28 additions & 0 deletions docs/operacao.md
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,34 @@ gunzip -c fidelityx-20261005-030000.sql.gz | docker compose exec -T db sh -c 'my

A restauração **substitui** as tabelas do banco de destino pelo conteúdo do backup. Depois dela, rode `php bin/migrate.php` (o backup pode ser anterior à última migration).

## Vencimento dos pontos

Cada loja tem um prazo de validade (`merchants.points_expiry_months`, padrão 12 meses; o lojista muda ou desliga na tela Regra de pontos). O saldo de um cliente vence quando ele fica esse tempo sem nenhuma movimentação naquela loja. Quem tira os pontos vencidos do saldo é este comando:

```bash
php bin/expire-points.php # vence os saldos parados
php bin/expire-points.php --dry-run # só mostra quantos cartões e pontos venceriam, sem alterar nada
```

Cada saldo vencido vira uma linha `expire` no histórico (`points_log`), que aparece no extrato do cliente e nos relatórios da loja. Rodar de novo não vence nada em dobro. Lojas inativas e cartões anonimizados ficam de fora. Em caso de falha o comando sai com código 1.

**Sem o agendamento nada vence**: a data aparece para o cliente, mas o saldo continua lá. Agende uma vez por dia, como o backup:

```
# cron no servidor (4h da manhã), com Docker
0 4 * * * cd /caminho/do/fidelityx && docker compose exec -T app php bin/expire-points.php >> /var/log/fidelityx-expire.log 2>&1

# cron sem Docker
0 4 * * * cd /caminho/do/fidelityx && php bin/expire-points.php >> /var/log/fidelityx-expire.log 2>&1
```

No Windows (XAMPP), use o Agendador de Tarefas chamando `C:\xampp\php\php.exe bin\expire-points.php` na pasta do projeto.

Cuidados:

- O vencimento não tem "desfazer". Antes da primeira execução em um banco com dados reais, rode com `--dry-run` e faça um backup.
- Se a loja diminuir o prazo, os saldos que já passam do prazo novo vencem na próxima execução.

## Registro de erros

- Exceção não tratada e erro fatal viram uma linha no log e a página 500. A página mostra um código curto (ex.: `ab12cd34`) e a mesma sequência aparece na linha do log: quando um lojista relatar o erro, procure por ela.
Expand Down
22 changes: 7 additions & 15 deletions public/css/app.css
Original file line number Diff line number Diff line change
Expand Up @@ -286,6 +286,12 @@ th.num {
color: var(--warning-text);
}

/* pontos vencidos por falta de movimentacao */
.badge-expire {
background: var(--error-soft);
color: var(--error-text);
}

.btn-secondary {
min-height: 44px;
background: transparent;
Expand Down Expand Up @@ -561,7 +567,7 @@ span.btn-secondary {
}
}

/* progresso ate o proximo premio: barra fina com o preenchimento na cor da marca */
/* progresso ate o proximo premio: frase em cima da barra (a barra em si e o .progress do components.css) */
.reward-progress {
margin-top: 0.75rem;
}
Expand All @@ -571,20 +577,6 @@ span.btn-secondary {
margin-bottom: 0.4rem;
}

.progress {
height: 0.6rem;
background: var(--bg-body);
border: 1px solid var(--border);
border-radius: 999px;
overflow: hidden;
}

.progress-bar {
height: 100%;
background: var(--primary);
border-radius: 999px;
}

/* cartaz: folha clara (--paper) com nome da loja e qr code; centralizado e legivel de longe */
.poster {
max-width: 520px;
Expand Down
Loading
Loading