Skip to content
Merged
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
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,7 @@ 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`, `reward_id` nos resgates). Toda mudança de saldo passa por `LoyaltyCardModel::addPoints` / `redeem`, 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`, `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`).

**Á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 Down
11 changes: 8 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,11 @@ Recentemente refatorado para adotar o padrão **MVC com separação clara de res
- ✅ **Cadastro de Lojistas** — Registro completo com validação de documentos (CPF/CNPJ), e-mail, telefone e confirmação de senha; máscaras de telefone e documento
- ✅ **Autenticação Segura** — Login com hash BCRYPT, renovação do ID de sessão, logout, bloqueio de contas inativas e limite de tentativas que trava só quem errou a senha, nunca a dona da conta
- ✅ **Atendimento pelo telefone** — A home é um único campo de telefone: cliente da loja vai direto para a tela do cliente; qualquer outro telefone vai para o cadastro rápido
- ✅ **Tela do cliente** — Nome e saldo no topo, progresso até o próximo prêmio, lançar pontos (atalhos +1/+5/+10) e resgatar os prêmios que o saldo já paga
- ✅ **Tela do cliente** — Nome e saldo no topo, progresso até o próximo prêmio, lançar pontos (atalhos +1/+5/+10 ou pelo valor da compra) e resgatar os prêmios que o saldo já paga
- ✅ **Confirmação e Desfazer** — Depois de lançar, mostra os pontos, o saldo novo e quanto falta para o próximo prêmio, com Desfazer por 30 s
- ✅ **Estorno** — Lançamento errado das últimas 24 h é estornado pelo extrato; o estorno vira um registro novo e o saldo nunca fica negativo
- ✅ **Regra de pontos** — A loja define "a cada R$ X, 1 ponto" e no balcão digita só o valor da compra (arredonda para baixo)
- ✅ **Clientes recentes** — Os 5 últimos clientes atendidos aparecem na home, a um toque
- ✅ **Cadastro rápido** — Telefone já preenchido, só o nome e o consentimento do cliente (gravado com data e versão do texto)
- ✅ **Prêmios** — Criar, editar, ativar/desativar e excluir (prêmio já resgatado só é desativado)
- ✅ **Resgate** — Confere o saldo e debita em transação, sem risco de gastar o mesmo ponto duas vezes
Expand All @@ -48,14 +52,15 @@ Recentemente refatorado para adotar o padrão **MVC com separação clara de res
|---|---|---|
| `home` (ou raiz) · `privacy` | público | Página inicial e política de privacidade |
| `merchant/register` · `merchant/login` · `merchant/logout` | público | Conta do lojista |
| `merchant/dashboard` | lojista | Home: guia de primeiros passos e busca pelo telefone (`?phone=`) |
| `merchant/customer?phone=` | lojista | Tela do cliente: lançar pontos, resgatar, registrar consentimento e excluir dados |
| `merchant/dashboard` | lojista | Home: busca pelo telefone (`?phone=`), clientes recentes e guia de primeiros passos |
| `merchant/customer?phone=` | lojista | Tela do cliente: lançar pontos (ou pelo valor), desfazer/estornar, resgatar, registrar consentimento e excluir dados |
| `merchant/customer-new?phone=` | lojista | Cadastro rápido de cliente nesta loja |
| `merchant/statement?phone=` | lojista | Extrato de pontos do cliente |
| `merchant/customers?q=&page=` | lojista | Clientes e saldos, com busca e paginação |
| `merchant/rewards` · `merchant/reward-edit?id=` | lojista | Catálogo de prêmios e edição |
| `merchant/reports?page=` | lojista | Indicadores e histórico de movimentações |
| `merchant/poster` | lojista | Cartaz com QR code e código da loja |
| `merchant/points-rule` | lojista | Regra de pontos pelo valor da compra |
| `merchant/profile` | lojista | Dados da loja e troca de senha |
| `customer/balance?loja=` | público | Consulta de saldo pelo telefone, na loja do código |

Expand Down
17 changes: 17 additions & 0 deletions database/migrations/004_estorno.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
-- fidelityx - migration 004 (estorno de lancamento de pontos, task 4)
-- 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/004_estorno.sql)

USE fidelityx;

-- estorno vira um registro novo no historico (nada e apagado), apontando para o lancamento estornado.
-- o UNIQUE em reverses_id garante que um lancamento so e estornado uma vez, mesmo com dois cliques ao mesmo tempo.
ALTER TABLE points_log
MODIFY type ENUM('earn', 'redeem', 'reversal') NOT NULL,
ADD COLUMN reverses_id BIGINT UNSIGNED NULL AFTER reward_id,
ADD UNIQUE KEY uq_points_log_reverses (reverses_id),
ADD CONSTRAINT fk_points_log_reverses
FOREIGN KEY (reverses_id) REFERENCES points_log(id)
ON DELETE CASCADE
ON UPDATE CASCADE;
10 changes: 10 additions & 0 deletions database/migrations/005_regra_pontos.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
-- fidelityx - migration 005 (pontos calculados pelo valor da compra, task 6)
-- 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/005_regra_pontos.sql)

USE fidelityx;

-- a cada points_rule_cents centavos em compras, 1 ponto (arredonda pra baixo). NULL = sem regra.
ALTER TABLE merchants
ADD COLUMN points_rule_cents INT UNSIGNED NULL AFTER plan;
13 changes: 12 additions & 1 deletion database/schema.sql
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,9 @@ CREATE TABLE IF NOT EXISTS merchants (
category VARCHAR(30) NOT NULL,
password_hash VARCHAR(255) NOT NULL,
plan ENUM('free', 'pro') NOT NULL DEFAULT 'free',
-- 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,
status ENUM('active', 'inactive') NOT NULL DEFAULT 'active',

-- para futuras implementações (NULL)
Expand Down Expand Up @@ -137,10 +140,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') NOT NULL,
type ENUM('earn', 'redeem', 'reversal') NOT NULL, -- reversal = estorno de um earn
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

-- para futuras implementações (NULL)
responsible_user VARCHAR(255) NULL,
Expand All @@ -154,6 +158,7 @@ CREATE TABLE IF NOT EXISTS points_log (
KEY idx_points_log_type (type),

KEY idx_points_log_reward (reward_id),
UNIQUE KEY uq_points_log_reverses (reverses_id), -- um lancamento so pode ser estornado uma vez

CONSTRAINT fk_points_log_card
FOREIGN KEY (card_id) REFERENCES loyalty_cards(id)
Expand All @@ -162,6 +167,10 @@ CREATE TABLE IF NOT EXISTS points_log (
CONSTRAINT fk_points_log_reward
FOREIGN KEY (reward_id) REFERENCES rewards(id)
ON DELETE SET NULL
ON UPDATE CASCADE,
CONSTRAINT fk_points_log_reverses
FOREIGN KEY (reverses_id) REFERENCES points_log(id)
ON DELETE CASCADE
ON UPDATE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;

Expand Down Expand Up @@ -196,4 +205,6 @@ INSERT IGNORE INTO schema_migrations (version) VALUES
('001_mvp'),
('002_rate_limit'),
('003_lgpd'),
('004_estorno'),
('005_regra_pontos'),
('006_points_log_extrato');
4 changes: 3 additions & 1 deletion docs/db/schema-explanation.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ merchants 1───N loyalty_cards N───1 customers
### `merchants` — lojistas
| 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. |
| `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 Down Expand Up @@ -58,8 +59,9 @@ merchants 1───N loyalty_cards N───1 customers
### `points_log` — histórico
| Coluna | Observação |
|---|---|
| `type` | `earn` (ganho) ou `redeem` (resgate). `quantity` é sempre positivo. |
| `type` | `earn` (ganho), `redeem` (resgate) ou `reversal` (estorno de um ganho). `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`). |
| í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`. |

Expand Down
97 changes: 97 additions & 0 deletions public/css/app.css
Original file line number Diff line number Diff line change
Expand Up @@ -220,6 +220,7 @@

/* tabela larga rola dentro do card, nunca a pagina inteira */
.table-wrap {
position: relative; /* elemento absoluto dentro da tabela (ex.: .visually-hidden) fica no scroll dela, nao alarga a pagina */
overflow-x: auto;
-webkit-overflow-scrolling: touch;
}
Expand Down Expand Up @@ -280,6 +281,11 @@ th.num {
color: var(--primary-hover);
}

.badge-reversal {
background: var(--warning-soft);
color: var(--warning-text);
}

.btn-secondary {
min-height: 44px;
background: transparent;
Expand Down Expand Up @@ -540,8 +546,12 @@ span.btn-secondary {
position: absolute;
width: 1px;
height: 1px;
margin: -1px;
padding: 0;
border: 0;
overflow: hidden;
clip: rect(0 0 0 0);
clip-path: inset(50%);
white-space: nowrap;
}

Expand Down Expand Up @@ -750,3 +760,90 @@ span.btn-secondary {
font-size: 1.25rem;
letter-spacing: 0.15em;
}

/* confirmacao do lancamento (novo saldo, quanto falta e Desfazer) */
.launch-confirm {
border-color: var(--success);
background: var(--success-soft);
}

.launch-confirm p {
margin-bottom: 0.5rem;
}

.launch-confirm-title strong:first-child {
color: var(--success-text);
}

.launch-undo {
margin-top: 0.75rem;
}

/* clientes atendidos recentemente (home): cada linha e um link grande, facil de tocar */
.recent-list {
list-style: none;
}

.recent-list li {
border-bottom: 1px solid var(--border);
}

.recent-list li:last-child {
border-bottom: none;
}

.recent-list a {
display: flex;
flex-direction: column;
justify-content: center;
min-height: 56px;
padding: 0.5rem 0;
text-decoration: none;
}

.recent-name {
font-weight: 600;
}

.recent-meta {
font-size: 0.85rem;
}

@media (min-width: 640px) {
.recent-list a {
flex-direction: row;
align-items: center;
justify-content: space-between;
gap: 1rem;
}
}

/* regra de pontos pelo valor da compra */
.rule-help,
.rule-current {
margin-bottom: 1rem;
}

.rule-example,
.amount-preview {
font-size: 0.85rem;
margin-top: 0.5rem;
}

.rule-clear {
margin-top: 1rem;
}

.rule-summary {
display: flex;
flex-direction: column;
gap: 0.75rem;
}

@media (min-width: 640px) {
.rule-summary {
flex-direction: row;
align-items: center;
justify-content: space-between;
}
}
1 change: 1 addition & 0 deletions public/index.php
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,7 @@
'customer' => $controller->renderCustomer(),
'statement' => $controller->renderStatement(),
'poster' => $controller->renderPoster(),
'points-rule' => $controller->renderPointsRule(),
'reports' => $controller->renderReports(),
'rewards' => $controller->renderRewards(),
'reward-edit' => $controller->renderRewardEdit(),
Expand Down
Loading
Loading