diff --git a/CLAUDE.md b/CLAUDE.md index 1558043..af2972f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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). diff --git a/README.md b/README.md index 5954313..26bda92 100644 --- a/README.md +++ b/README.md @@ -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 @@ -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 | diff --git a/database/migrations/004_estorno.sql b/database/migrations/004_estorno.sql new file mode 100644 index 0000000..2f4d092 --- /dev/null +++ b/database/migrations/004_estorno.sql @@ -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; diff --git a/database/migrations/005_regra_pontos.sql b/database/migrations/005_regra_pontos.sql new file mode 100644 index 0000000..b9d5663 --- /dev/null +++ b/database/migrations/005_regra_pontos.sql @@ -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; diff --git a/database/schema.sql b/database/schema.sql index 2b3e7e9..0bb6c4b 100644 --- a/database/schema.sql +++ b/database/schema.sql @@ -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) @@ -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, @@ -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) @@ -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; @@ -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'); diff --git a/docs/db/schema-explanation.md b/docs/db/schema-explanation.md index 2ce4bc8..e6aa59c 100644 --- a/docs/db/schema-explanation.md +++ b/docs/db/schema-explanation.md @@ -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`). | @@ -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`. | diff --git a/public/css/app.css b/public/css/app.css index cf7b4ae..c0179fe 100644 --- a/public/css/app.css +++ b/public/css/app.css @@ -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; } @@ -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; @@ -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; } @@ -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; + } +} diff --git a/public/index.php b/public/index.php index 8496294..6d6354a 100644 --- a/public/index.php +++ b/public/index.php @@ -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(), diff --git a/src/Controllers/MerchantController.php b/src/Controllers/MerchantController.php index 7c29d4a..b534529 100644 --- a/src/Controllers/MerchantController.php +++ b/src/Controllers/MerchantController.php @@ -8,6 +8,7 @@ use App\Models\PointsLogModel; use App\Models\RewardModel; use App\Support\Csrf; +use App\Support\Money; use App\Support\Paginator; use App\Support\Privacy; use App\Support\QrSvg; @@ -37,6 +38,12 @@ class MerchantController { private const MAX_POINTS_PER_ENTRY = 10000; private const PER_PAGE = 20; + // 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; + private const UNDO_BANNER_SECONDS = 120; + private const RECENT_CUSTOMERS = 5; + private const MAX_POINTS_RULE_CENTS = 100000000; private const MAX_LOGIN_FAILURES = 5; // mesmo e-mail + mesmo ip private const MAX_LOGIN_FAILURES_PER_IP = 20; // mesmo ip, somando todas as contas private const LOGIN_WINDOW_SECONDS = 900; @@ -47,6 +54,8 @@ class MerchantController { private $db; private $merchantModel; + // lojista logado, preenchido pelo authGuard + private ?array $merchant = null; public function __construct($db) { $this->db = $db; @@ -93,7 +102,10 @@ public function renderDashboard() { $merchantId = $this->authGuard(); if (!isset($_GET['phone'])) { - View::render('merchant/dashboard', ['steps' => $this->onboardingSteps($merchantId)]); + View::render('merchant/dashboard', [ + 'steps' => $this->onboardingSteps($merchantId), + 'recent' => $this->recentCustomers($merchantId), + ]); return; } @@ -109,6 +121,49 @@ public function renderDashboard() { redirect('merchant/customer-new', ['phone' => $phone]); } + // regra de pontos da loja logada, em centavos por ponto (null = sem regra). so depois do authGuard. + private function pointsRule(): ?int { + $rule = $this->merchant['points_rule_cents'] ?? null; + return $rule === null ? null : (int)$rule; + } + + // regra de pontos pelo valor da compra (task 6): "a cada R$ X em compras, 1 ponto" + public function renderPointsRule() { + $merchantId = $this->authGuard(); + + if ($_SERVER['REQUEST_METHOD'] === 'POST') { + $this->handlePointsRule($merchantId); + return; + } + + View::render('merchant/points-rule', ['ruleCents' => $this->pointsRule()]); + } + + private function handlePointsRule(int $merchantId) { + Csrf::verify(); + + if (($_POST['action'] ?? '') === 'clear') { + $this->merchantModel->updatePointsRule($merchantId, null); + redirect('merchant/points-rule', ['success' => 'regra_removida']); + } + + // de R$ 0,01 (1 centavo = 1 ponto) ate R$ 1.000.000,00 por ponto + $cents = Money::toCents($_POST['rule'] ?? ''); + if ($cents === null || $cents < 1 || $cents > self::MAX_POINTS_RULE_CENTS) { + redirect('merchant/points-rule', ['error' => 'regra_invalida']); + } + + $this->merchantModel->updatePointsRule($merchantId, $cents); + redirect('merchant/points-rule', ['success' => 'regra_salva']); + } + + // atalho da home: os ultimos clientes atendidos (lancamento, resgate ou estorno), para quem volta no mesmo dia. + // cadastro sem nenhuma movimentacao ainda nao conta como atendimento. + private function recentCustomers(int $merchantId): array { + $rows = (new LoyaltyCardModel($this->db))->searchByMerchant($merchantId, '', self::RECENT_CUSTOMERS, 0); + return array_values(array_filter($rows, fn($row) => $row['last_use_at'] !== null)); + } + // guia de primeiros passos da home: cada passo sabe se ja foi feito pelos dados da propria loja. // devolve lista vazia quando tudo esta feito (a view some com o guia). private function onboardingSteps(int $merchantId): array { @@ -127,6 +182,15 @@ private function onboardingSteps(int $merchantId): array { 'cta' => 'Buscar cliente', 'done' => (new PointsLogModel($this->db))->countByMerchant($merchantId) > 0, ], + [ + // opcional: lancar pontos direto continua valendo + 'label' => 'Defina a regra de pontos', + 'hint' => 'Ex.: a cada R$ 1,00 em compras, 1 ponto. Aí é só digitar o valor da compra.', + 'url' => url('merchant/points-rule'), + 'cta' => 'Definir regra', + 'done' => $this->pointsRule() !== null, + 'optional' => true, + ], [ // nao da pra saber se imprimiu: e uma dica (opcional) que nao segura o guia na tela 'label' => 'Imprima o cartaz com o QR code', @@ -210,10 +274,20 @@ public function renderCustomer() { fn($reward) => (int)$reward['points_cost'] <= $balance )); + // confirmacao do lancamento que acabou de ser feito (task 5). so aparece logo depois de lancar: + // recarregar a pagina minutos depois nao mostra de novo nem oferece Desfazer. + $launchId = filter_var($_GET['lancamento'] ?? '', FILTER_VALIDATE_INT); + $launch = $launchId + ? (new PointsLogModel($this->db))->findFreshEarn($launchId, (int)$card['id'], self::UNDO_BANNER_SECONDS) + : false; + View::render('merchant/customer', [ - 'card' => $card, - 'rewards' => $rewards, - 'progress' => RewardProgress::next($balance, $activeRewards), + 'card' => $card, + 'rewards' => $rewards, + 'progress' => RewardProgress::next($balance, $activeRewards), + 'launch' => $launch ?: null, + 'undoSeconds' => self::UNDO_SECONDS, + 'pointsRule' => $this->pointsRule(), ]); } @@ -265,7 +339,8 @@ public function renderRewards() { } View::render('merchant/rewards', [ - 'rewards' => $rewardModel->listByMerchant($merchantId), + 'rewards' => $rewardModel->listByMerchant($merchantId), + 'pointsRule' => $this->pointsRule(), ]); } @@ -547,6 +622,24 @@ 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. + 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 && $this->logBelongsToCard($logId, (int)$card['id']) + ? $cardModel->reverse($logId, $merchantId) + : 'not_found'; + + redirect($back, ['phone' => $phone] + match ($result) { + 'ok' => ['success' => 'lancamento_estornado'], + 'already' => ['error' => 'estorno_repetido'], + 'expired' => ['error' => 'estorno_expirado'], + 'insufficient' => ['error' => 'estorno_sem_saldo'], + default => ['error' => 'estorno_invalido'], + }); + } + if (($_POST['action'] ?? '') === 'redeem') { $rewardId = filter_var($_POST['reward_id'] ?? '', FILTER_VALIDATE_INT); $reward = $rewardId ? (new RewardModel($this->db))->findActiveForMerchant($rewardId, $merchantId) : null; @@ -562,19 +655,48 @@ private function handleCustomer($merchantId) { redirect('merchant/customer', ['phone' => $phone, 'success' => 'resgate_realizado']); } - $points = filter_var($_POST['points'] ?? '', FILTER_VALIDATE_INT, [ - 'options' => ['min_range' => 1, 'max_range' => self::MAX_POINTS_PER_ENTRY], - ]); + // com regra de pontos e valor da compra digitado, os pontos saem do valor (task 6), sempre + // arredondando pra baixo e calculados aqui (a previa da tela e so ajuda). sem valor, vale o campo de pontos. + $amount = trim((string)($_POST['amount'] ?? '')); + $rule = $this->pointsRule(); + $defaultDescription = 'Compra'; + + if ($amount !== '' && $rule) { + $cents = Money::toCents($amount); + if ($cents === null || $cents < 1) { + redirect('merchant/customer', ['phone' => $phone, 'error' => 'valor_invalido']); + } + $points = Money::pointsFor($cents, $rule); + if ($points < 1) { + redirect('merchant/customer', ['phone' => $phone, 'error' => 'valor_sem_pontos']); + } + if ($points > self::MAX_POINTS_PER_ENTRY) { + redirect('merchant/customer', ['phone' => $phone, 'error' => 'pontos_invalidos']); + } + $defaultDescription = 'Compra de ' . Money::format($cents); + } else { + $points = filter_var($_POST['points'] ?? '', FILTER_VALIDATE_INT, [ + 'options' => ['min_range' => 1, 'max_range' => self::MAX_POINTS_PER_ENTRY], + ]); + } + $description = trim((string)($_POST['description'] ?? '')); - $description = $description === '' ? 'Compra' : mb_substr($description, 0, 255); + $description = $description === '' ? $defaultDescription : mb_substr($description, 0, 255); if ($points === false) { redirect('merchant/customer', ['phone' => $phone, 'error' => 'pontos_invalidos']); } - $cardModel->addPoints($card['id'], $points, $description); + $logId = $cardModel->addPoints($card['id'], $points, $description); + + // a tela do cliente mostra a confirmacao (novo saldo, quanto falta, Desfazer) a partir do lancamento + redirect('merchant/customer', ['phone' => $phone, 'lancamento' => $logId]); + } - redirect('merchant/customer', ['phone' => $phone, 'success' => 'pontos_lancados']); + private function logBelongsToCard(int $logId, int $cardId): bool { + $stmt = $this->db->prepare('SELECT 1 FROM points_log WHERE id = :id AND card_id = :card_id'); + $stmt->execute([':id' => $logId, ':card_id' => $cardId]); + return (bool)$stmt->fetchColumn(); } // criar, ativar/desativar ou excluir premio @@ -787,6 +909,9 @@ private function authGuard(): int { redirect('merchant/login', ['error' => 'sessao_expirada']); } + // a loja da requisicao fica a mao (ex.: regra de pontos), sem consultar o banco de novo + $this->merchant = $merchant; + // 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/LoyaltyCardModel.php b/src/Models/LoyaltyCardModel.php index e3b698f..b57f331 100644 --- a/src/Models/LoyaltyCardModel.php +++ b/src/Models/LoyaltyCardModel.php @@ -47,8 +47,8 @@ public function findByMerchantAndPhone($merchantId, $phone) { return $stmt->fetch(\PDO::FETCH_ASSOC); } - // soma pontos no cartao e registra no historico (tudo ou nada) - public function addPoints($cardId, $points, $description) { + // soma pontos no cartao e registra no historico (tudo ou nada). devolve o id do lancamento. + public function addPoints($cardId, $points, $description): int { $this->db->beginTransaction(); try { $stmt = $this->db->prepare( @@ -60,9 +60,76 @@ public function addPoints($cardId, $points, $description) { ); $stmt->execute([':p1' => $points, ':p2' => $points, ':id' => $cardId]); - $this->log($cardId, 'earn', $points, $description, null); + $logId = $this->log($cardId, 'earn', $points, $description, null); $this->db->commit(); + return $logId; + } catch (\Throwable $e) { + $this->db->rollBack(); + throw $e; + } + } + + // 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). + // devolve 'ok', 'not_found', 'already', 'expired' ou 'insufficient'. + public const REVERSAL_WINDOW_HOURS = 24; + + public function reverse($logId, $merchantId): string { + $this->db->beginTransaction(); + try { + $stmt = $this->db->prepare( + "SELECT pl.id, pl.card_id, 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" + ); + $stmt->execute([':id' => $logId, ':merchant_id' => $merchantId]); + $entry = $stmt->fetch(\PDO::FETCH_ASSOC); + + $problem = match (true) { + !$entry => 'not_found', + (bool)$entry['reversed'] => 'already', + !$entry['recent'] => 'expired', + default => null, + }; + if ($problem) { + $this->db->rollBack(); + return $problem; + } + + // trava o cartao: um resgate ao mesmo tempo nao consegue gastar os pontos que estao sendo estornados + $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']]); + + $description = mb_substr('Estorno: ' . $entry['description'], 0, 255); + $this->log($entry['card_id'], 'reversal', $quantity, $description, null, (int)$entry['id']); + + $this->db->commit(); + return 'ok'; + } catch (\PDOException $e) { + $this->db->rollBack(); + // dois estornos do mesmo lancamento ao mesmo tempo: o UNIQUE barra o segundo + if (($e->errorInfo[1] ?? null) === 1062) { + return 'already'; + } + throw $e; } catch (\Throwable $e) { $this->db->rollBack(); throw $e; @@ -185,7 +252,7 @@ public function anonymize($cardId, $merchantId): bool { )->execute([':id' => $cardId]); $this->db->prepare( - "UPDATE points_log SET description = 'Cliente excluído' WHERE card_id = :id AND type = 'earn'" + "UPDATE points_log SET description = 'Cliente excluído' WHERE card_id = :id AND type IN ('earn', 'reversal')" )->execute([':id' => $cardId]); if ($customerId !== null) { @@ -204,10 +271,11 @@ public function anonymize($cardId, $merchantId): bool { } } - private function log($cardId, $type, $quantity, $description, $rewardId) { + // grava uma linha no historico e devolve o id dela + private function log($cardId, $type, $quantity, $description, $rewardId, $reversesId = null): int { $stmt = $this->db->prepare( - 'INSERT INTO points_log (card_id, type, quantity, description, reward_id, ip_address) - VALUES (:card_id, :type, :quantity, :description, :reward_id, :ip)' + 'INSERT INTO points_log (card_id, type, quantity, description, reward_id, reverses_id, ip_address) + VALUES (:card_id, :type, :quantity, :description, :reward_id, :reverses_id, :ip)' ); $stmt->execute([ ':card_id' => $cardId, @@ -215,7 +283,10 @@ private function log($cardId, $type, $quantity, $description, $rewardId) { ':quantity' => $quantity, ':description' => $description, ':reward_id' => $rewardId, + ':reverses_id' => $reversesId, ':ip' => isset($_SERVER['REMOTE_ADDR']) ? \App\Support\RateLimiter::clientIp() : null, ]); + + return (int)$this->db->lastInsertId(); } } diff --git a/src/Models/MerchantModel.php b/src/Models/MerchantModel.php index 3552d44..bb530a7 100644 --- a/src/Models/MerchantModel.php +++ b/src/Models/MerchantModel.php @@ -42,12 +42,18 @@ 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, password_hash FROM merchants WHERE id = :id'); + $stmt = $this->db->prepare('SELECT id, owner_name, store_name, status, points_rule_cents, password_hash FROM merchants WHERE id = :id'); $stmt->execute([':id' => $merchantId]); return $stmt->fetch(\PDO::FETCH_ASSOC); } + // regra de pontos pelo valor da compra (centavos por ponto); null remove a regra + public function updatePointsRule($merchantId, ?int $ruleCents): void { + $stmt = $this->db->prepare('UPDATE merchants SET points_rule_cents = :rule WHERE id = :id'); + $stmt->execute([':rule' => $ruleCents, ':id' => $merchantId]); + } + // dados exibidos na tela de perfil; false se a conta nao existir public function findProfile($merchantId) { $stmt = $this->db->prepare( diff --git a/src/Models/PointsLogModel.php b/src/Models/PointsLogModel.php index 60534e7..eab2ae9 100644 --- a/src/Models/PointsLogModel.php +++ b/src/Models/PointsLogModel.php @@ -32,10 +32,14 @@ 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) { - $sql = 'SELECT type, quantity, description, created_at - FROM points_log - WHERE card_id = :card_id - ORDER BY created_at DESC, id DESC + // can_reverse: lancamento que ainda pode ser estornado (o model confere tudo de novo no POST) + $sql = 'SELECT pl.id, pl.type, pl.quantity, pl.description, pl.created_at, + (pl.type = \'earn\' + 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 + WHERE pl.card_id = :card_id + ORDER BY pl.created_at DESC, pl.id DESC LIMIT ' . (int)$limit . ' OFFSET ' . (int)$offset; $stmt = $this->db->prepare($sql); $stmt->execute([':card_id' => $cardId]); @@ -43,6 +47,18 @@ public function pageByCard($cardId, int $limit, int $offset) { return $stmt->fetchAll(\PDO::FETCH_ASSOC); } + // lancamento (earn) deste cartao, feito ha no maximo $maxAgeSeconds e ainda nao estornado; false se nao houver + public function findFreshEarn($logId, $cardId, int $maxAgeSeconds) { + $sql = "SELECT id, quantity FROM points_log pl + WHERE pl.id = :id AND pl.card_id = :card_id AND pl.type = 'earn' + AND pl.created_at >= NOW() - INTERVAL " . (int)$maxAgeSeconds . " SECOND + AND NOT EXISTS (SELECT 1 FROM points_log r WHERE r.reverses_id = pl.id)"; + $stmt = $this->db->prepare($sql); + $stmt->execute([':id' => $logId, ':card_id' => $cardId]); + + return $stmt->fetch(\PDO::FETCH_ASSOC); + } + public function countByCard($cardId): int { $stmt = $this->db->prepare('SELECT COUNT(*) FROM points_log WHERE card_id = :card_id'); $stmt->execute([':card_id' => $cardId]); @@ -64,9 +80,9 @@ public function countByMerchant($merchantId): int { 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(pl.quantity), 0) FROM points_log pl + (SELECT COALESCE(SUM(CASE pl.type WHEN '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 = 'earn') AS points_issued, + WHERE lc.merchant_id = :m2 AND pl.type IN ('earn', 'reversal')) 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, diff --git a/src/Support/Money.php b/src/Support/Money.php new file mode 100644 index 0000000..afdfb3a --- /dev/null +++ b/src/Support/Money.php @@ -0,0 +1,45 @@ + centavos. aceita "12,90", "12.90", "1.234,56", "R$ 12,90", "12". + // com virgula, a virgula e a decimal e o ponto e milhar; sem virgula, ponto seguido de 1 ou 2 + // digitos e decimal ("12.9"), senao e milhar ("1.234"). devolve null se nao for um valor. + public static function toCents($input): ?int { + $value = preg_replace('/[^\d,.]/', '', (string)$input); + if ($value === '' || substr_count($value, ',') > 1) { + return null; + } + + if (str_contains($value, ',')) { + [$int, $dec] = explode(',', str_replace('.', '', $value), 2); + } else { + $parts = explode('.', $value); + $last = end($parts); + if (count($parts) > 1 && strlen($last) <= 2) { + $dec = array_pop($parts); + } else { + $dec = ''; + } + $int = implode('', $parts); + } + + if (($int === '' && $dec === '') || strlen($dec) > 2 || strlen($int) > 9) { + return null; + } + + return (int)($int === '' ? '0' : $int) * 100 + (int)str_pad($dec, 2, '0'); + } + + // centavos -> "R$ 1.234,56" + public static function format(int $cents): string { + return 'R$ ' . number_format($cents / 100, 2, ',', '.'); + } + + // pontos de uma compra pela regra (a cada $ruleCents, 1 ponto), sempre arredondando pra baixo + public static function pointsFor(int $amountCents, int $ruleCents): int { + return $ruleCents > 0 ? intdiv(max(0, $amountCents), $ruleCents) : 0; + } +} diff --git a/src/Support/helpers.php b/src/Support/helpers.php index bdd8433..e8ed919 100644 --- a/src/Support/helpers.php +++ b/src/Support/helpers.php @@ -56,3 +56,13 @@ function format_datetime($value): string { 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, '', ''], + }; +} diff --git a/tests/Feature/BalcaoFlowTest.php b/tests/Feature/BalcaoFlowTest.php index 0e31644..7da6958 100644 --- a/tests/Feature/BalcaoFlowTest.php +++ b/tests/Feature/BalcaoFlowTest.php @@ -38,7 +38,7 @@ public function testFluxoCompletoDoCadastroDaLojaAoResgate(): void { // tela do cliente: lancar pontos e o saldo atualiza na hora $screen = 'merchant/customer&phone=11911110001'; [, $location] = $this->post('merchant/customer', ['action' => 'score', 'phone' => '11911110001', 'points' => 30], true, $screen); - $this->assertSame('merchant/customer&phone=11911110001&success=pontos_lancados', $location); + $this->assertMatchesRegularExpression('/^merchant\/customer&phone=11911110001&lancamento=\d+$/', $location); $this->get($screen); $this->assertStringContainsString('balance-value">30<', $this->lastBody); $this->assertStringContainsString('name="reward_id" value="' . $cafeId . '"', $this->lastBody, 'premio que o saldo paga aparece'); diff --git a/tests/Feature/LaunchConfirmationTest.php b/tests/Feature/LaunchConfirmationTest.php new file mode 100644 index 0000000..b143e3c --- /dev/null +++ b/tests/Feature/LaunchConfirmationTest.php @@ -0,0 +1,82 @@ +createMerchant('loja@teste.test'); + $this->createReward($merchant, 'Bolo', 100); + $card = $this->createCard($merchant, 'Bia Souza', '11922220002'); + if ($points) { + $this->db->exec("UPDATE loyalty_cards SET current_points = $points WHERE id = $card"); + } + $this->loginAs('loja@teste.test'); + return $card; + } + + private function launch(int $points): string { + [, $location] = $this->post('merchant/customer', ['action' => 'score', 'phone' => '11922220002', 'points' => $points], true, self::SCREEN); + return $location; + } + + public function testConfirmacaoMostraPontosNovoSaldoEQuantoFalta(): void { + $this->setUpStore(27); + + $location = $this->launch(30); + $this->assertMatchesRegularExpression('/^merchant\/customer&phone=11922220002&lancamento=\d+$/', $location); + + $this->get($location); + $this->assertMatchesRegularExpression('#\+30 pontos lançados\s*para Bia\.#', $this->lastBody); + $this->assertStringContainsString('Saldo agora: 57 pontos', $this->lastBody); + $this->assertStringContainsString('Faltam 43 para Bolo', $this->lastBody); + $this->assertStringContainsString('name="action" value="reverse"', $this->lastBody); + $this->assertStringContainsString('data-undo-seconds="30"', $this->lastBody); + } + + public function testDesfazerVoltaOSaldoEAConfirmacaoSome(): void { + $this->setUpStore(27); + $location = $this->launch(30); + $logId = (int)substr($location, strrpos($location, '=') + 1); + + [, $back] = $this->post('merchant/customer', ['action' => 'reverse', 'phone' => '11922220002', 'log_id' => $logId], true, $location); + $this->assertSame(self::SCREEN . '&success=lancamento_estornado', $back); + $this->assertSame(27, (int)$this->scalar('SELECT current_points FROM loyalty_cards')); + + // abrir de novo o link da confirmacao nao oferece desfazer de novo + $this->get($location); + $this->assertStringNotContainsString('pontos lançados', $this->lastBody); + } + + public function testConfirmacaoSoAparecePraLancamentoRecenteDesteCliente(): void { + $card = $this->setUpStore(); + $location = $this->launch(10); + $logId = (int)substr($location, strrpos($location, '=') + 1); + + // passou do tempo do aviso + $this->db->exec("UPDATE points_log SET created_at = NOW() - INTERVAL 10 MINUTE WHERE id = $logId"); + $this->get($location); + $this->assertStringNotContainsString('pontos lançados', $this->lastBody); + + // lancamento de outro cliente no link + $other = $this->createCard((int)$this->scalar('SELECT merchant_id FROM loyalty_cards WHERE id = ?', [$card]), 'Caio', '11933330003'); + $this->launchFor('11933330003'); + $otherLog = (int)$this->scalar('SELECT MAX(id) FROM points_log WHERE card_id = ?', [$other]); + $this->get(self::SCREEN . '&lancamento=' . $otherLog); + $this->assertStringNotContainsString('pontos lançados', $this->lastBody); + } + + private function launchFor(string $phone): void { + $this->post('merchant/customer', ['action' => 'score', 'phone' => $phone, 'points' => 5], true, 'merchant/customer&phone=' . $phone); + } + + public function testQuandoPagaTudoDizQueJaDaParaResgatar(): void { + $this->setUpStore(90); + $this->get($this->launch(20)); + $this->assertStringContainsString('Já dá para resgatar qualquer prêmio.', $this->lastBody); + } +} diff --git a/tests/Feature/PointsRuleTest.php b/tests/Feature/PointsRuleTest.php new file mode 100644 index 0000000..addb7e6 --- /dev/null +++ b/tests/Feature/PointsRuleTest.php @@ -0,0 +1,128 @@ +createMerchant('loja@teste.test'); + if ($ruleCents !== null) { + $this->db->exec("UPDATE merchants SET points_rule_cents = $ruleCents WHERE id = $merchant"); + } + $this->createCard($merchant, 'Bia', '11922220002'); + $this->loginAs('loja@teste.test'); + return $merchant; + } + + private function launch(array $fields): ?string { + return $this->post('merchant/customer', ['action' => 'score', 'phone' => '11922220002'] + $fields, true, self::SCREEN)[1]; + } + + public function testDefineAlteraERemoveARegra(): void { + $merchant = $this->setUpStore(null); + + [, $location] = $this->post('merchant/points-rule', ['action' => 'save', 'rule' => '1,00'], true, 'merchant/points-rule'); + $this->assertSame('merchant/points-rule&success=regra_salva', $location); + $this->assertSame(100, (int)$this->scalar('SELECT points_rule_cents FROM merchants WHERE id = ?', [$merchant])); + + $this->get('merchant/points-rule'); + $this->assertStringContainsString('a cada R$ 1,00 em compras', $this->lastBody); + + $this->post('merchant/points-rule', ['action' => 'save', 'rule' => '5'], true, 'merchant/points-rule'); + $this->assertSame(500, (int)$this->scalar('SELECT points_rule_cents FROM merchants WHERE id = ?', [$merchant])); + + [, $location] = $this->post('merchant/points-rule', ['action' => 'clear'], true, 'merchant/points-rule'); + $this->assertSame('merchant/points-rule&success=regra_removida', $location); + $this->assertNull($this->scalar('SELECT points_rule_cents FROM merchants WHERE id = ?', [$merchant])); + } + + public function testRegraInvalidaERecusada(): void { + $merchant = $this->setUpStore(null); + + foreach (['0', 'abc', '', '0,001', '2000000'] as $rule) { + [, $location] = $this->post('merchant/points-rule', ['action' => 'save', 'rule' => $rule], true, 'merchant/points-rule'); + $this->assertSame('merchant/points-rule&error=regra_invalida', $location, $rule); + } + $this->assertNull($this->scalar('SELECT points_rule_cents FROM merchants WHERE id = ?', [$merchant])); + } + + public function testValorDaCompraViraPontosArredondandoParaBaixo(): void { + $this->setUpStore(100); + + $this->get(self::SCREEN); + $this->assertStringContainsString('name="amount"', $this->lastBody); + $this->assertStringContainsString('data-rule-cents="100"', $this->lastBody); + + $location = $this->launch(['amount' => '12,90']); + $this->assertMatchesRegularExpression('/lancamento=\d+$/', $location); + $this->assertSame(12, (int)$this->scalar('SELECT current_points FROM loyalty_cards')); + $this->assertSame('Compra de R$ 12,90', $this->scalar('SELECT description FROM points_log')); + } + + public function testOServidorRecalculaEIgnoraPontosDiferentesDoValor(): void { + $this->setUpStore(500); + + // a previa da tela poderia ter sido adulterada: vale o valor da compra + $this->launch(['amount' => '14,99', 'points' => '9999']); + $this->assertSame(2, (int)$this->scalar('SELECT current_points FROM loyalty_cards')); + } + + public function testSemValorValeOCampoDePontos(): void { + $this->setUpStore(100); + + $this->launch(['amount' => '', 'points' => '7', 'description' => 'Brinde']); + $this->assertSame(7, (int)$this->scalar('SELECT current_points FROM loyalty_cards')); + $this->assertSame('Brinde', $this->scalar('SELECT description FROM points_log')); + } + + public function testValorInvalidoOuAbaixoDeUmPontoNaoLanca(): void { + $this->setUpStore(500); + + $this->assertSame(self::SCREEN . '&error=valor_invalido', $this->launch(['amount' => 'abc'])); + $this->assertSame(self::SCREEN . '&error=valor_sem_pontos', $this->launch(['amount' => '4,99'])); + $this->assertSame(0, (int)$this->scalar('SELECT COUNT(*) FROM points_log')); + } + + public function testValorQuePassaDoLimiteDePontosNaoLanca(): void { + $this->setUpStore(1); // R$ 0,01 = 1 ponto + + $this->assertSame(self::SCREEN . '&error=pontos_invalidos', $this->launch(['amount' => '200,00'])); + $this->assertSame(0, (int)$this->scalar('SELECT COUNT(*) FROM points_log')); + } + + public function testSemRegraOValorEIgnoradoETelaOfereceDefinir(): void { + $this->setUpStore(null); + + $this->get(self::SCREEN); + $this->assertStringNotContainsString('name="amount"', $this->lastBody); + $this->assertStringContainsString('merchant%2Fpoints-rule', $this->lastBody); + + $this->assertSame(self::SCREEN . '&error=pontos_invalidos', $this->launch(['amount' => '50,00'])); + } + + public function testGuiaDePrimeirosPassosTemARegraComoOpcional(): void { + $merchant = $this->setUpStore(null); + + $this->get('merchant/dashboard'); + $this->assertStringContainsString('Defina a regra de pontos', $this->lastBody); + + $this->db->exec("UPDATE merchants SET points_rule_cents = 100 WHERE id = $merchant"); + $this->get('merchant/dashboard'); + $this->assertMatchesRegularExpression('#step step-done">\s*\s*
+ += number_format((int)$launch['quantity'], 0, ',', '.') ?> = (int)$launch['quantity'] === 1 ? 'ponto lançado' : 'pontos lançados' ?> + para = e(strtok((string)$card['customer_name'], ' ')) ?>. + Saldo agora: = number_format((int)$card['current_points'], 0, ',', '.') ?> pontos. +
+ +Faltam = number_format($progress['missing'], 0, ',', '.') ?> para = e($progress['reward']['name']) ?>.
+ +Já dá para resgatar qualquer prêmio.
+ + +